diff --git a/lib/config/config.go b/lib/config/config.go new file mode 100644 index 0000000..a448cc7 --- /dev/null +++ b/lib/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +type Config struct { + Addr string + BadgerDir string + PageSize int + ShutdownTimeout time.Duration +} + +func Load() Config { + return Config{ + Addr: envOrDefault("APP_ADDR", ":8080"), + BadgerDir: envOrDefault("BADGER_DIR", "data/badger"), + PageSize: envIntOrDefault("PAGE_SIZE", 50), + ShutdownTimeout: envDurationOrDefault("SHUTDOWN_TIMEOUT", 10*time.Second), + } +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + + return fallback +} + +func envIntOrDefault(key string, fallback int) int { + value := os.Getenv(key) + if value == "" { + return fallback + } + + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fallback + } + + return parsed +} + +func envDurationOrDefault(key string, fallback time.Duration) time.Duration { + value := os.Getenv(key) + if value == "" { + return fallback + } + + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return fallback + } + + return parsed +} diff --git a/models.go b/lib/model/submission.go similarity index 82% rename from models.go rename to lib/model/submission.go index 91c747f..130a101 100644 --- a/models.go +++ b/lib/model/submission.go @@ -1,4 +1,4 @@ -package main +package model import ( "errors" @@ -102,7 +102,7 @@ func (b BenchmarkResult) Validate() error { return nil } -func normalizeSubmitter(submitter string) string { +func NormalizeSubmitter(submitter string) string { submitter = strings.TrimSpace(submitter) if submitter == "" { return "Anonymous" @@ -111,10 +111,31 @@ func normalizeSubmitter(submitter string) string { return submitter } -func threadModeLabel(multiCore bool) string { +func ThreadModeLabel(multiCore bool) string { if multiCore { return "Multi-threaded" } return "Single-threaded" } + +func CloneSubmission(submission *Submission) *Submission { + if submission == nil { + return nil + } + + copySubmission := *submission + if len(submission.CoreResults) > 0 { + copySubmission.CoreResults = append([]CoreResult(nil), submission.CoreResults...) + } + + if len(submission.CPUInfo.Cores) > 0 { + copySubmission.CPUInfo.Cores = append([]CPUCoreDescriptor(nil), submission.CPUInfo.Cores...) + } + + if len(submission.CPUInfo.SupportedFeatures) > 0 { + copySubmission.CPUInfo.SupportedFeatures = append([]string(nil), submission.CPUInfo.SupportedFeatures...) + } + + return ©Submission +} diff --git a/db.go b/lib/store/store.go similarity index 68% rename from db.go rename to lib/store/store.go index fdffa69..c6cf185 100644 --- a/db.go +++ b/lib/store/store.go @@ -1,4 +1,4 @@ -package main +package store import ( "encoding/json" @@ -9,6 +9,8 @@ import ( "sync" "time" + "cpu-benchmark-server/lib/model" + "github.com/dgraph-io/badger/v4" "github.com/google/uuid" ) @@ -16,10 +18,9 @@ import ( const submissionPrefix = "submission:" type indexedSubmission struct { - submission *Submission - searchText string - cpuText string - submittedAt time.Time + submission *model.Submission + searchText string + cpuText string } type Store struct { @@ -29,7 +30,7 @@ type Store struct { records map[string]*indexedSubmission } -func OpenStore(path string) (*Store, error) { +func Open(path string) (*Store, error) { opts := badger.DefaultOptions(path).WithLogger(nil) db, err := badger.Open(opts) if err != nil { @@ -60,10 +61,10 @@ func (s *Store) Count() int { return len(s.orderedIDs) } -func (s *Store) SaveSubmission(result BenchmarkResult, submitter string) (*Submission, error) { - submission := &Submission{ +func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter string) (*model.Submission, error) { + submission := &model.Submission{ SubmissionID: uuid.NewString(), - Submitter: normalizeSubmitter(submitter), + Submitter: model.NormalizeSubmitter(submitter), SubmittedAt: time.Now().UTC(), BenchmarkResult: result, } @@ -87,16 +88,16 @@ func (s *Store) SaveSubmission(result BenchmarkResult, submitter string) (*Submi s.orderedIDs = append([]string{submission.SubmissionID}, s.orderedIDs...) s.mu.Unlock() - return cloneSubmission(submission), nil + return model.CloneSubmission(submission), nil } -func (s *Store) ListSubmissions(page, pageSize int) ([]Submission, int) { +func (s *Store) ListSubmissions(page, pageSize int) ([]model.Submission, int) { s.mu.RLock() defer s.mu.RUnlock() total := len(s.orderedIDs) start, end, _ := pageBounds(page, pageSize, total) - results := make([]Submission, 0, max(0, end-start)) + results := make([]model.Submission, 0, max(0, end-start)) for _, id := range s.orderedIDs[start:end] { record := s.records[id] @@ -104,20 +105,20 @@ func (s *Store) ListSubmissions(page, pageSize int) ([]Submission, int) { continue } - results = append(results, *cloneSubmission(record.submission)) + results = append(results, *model.CloneSubmission(record.submission)) } return results, total } -func (s *Store) SearchSubmissions(text, cpu string) []Submission { +func (s *Store) SearchSubmissions(text, cpu string) []model.Submission { queryText := normalizeSearchText(text) cpuText := normalizeSearchText(cpu) s.mu.RLock() defer s.mu.RUnlock() - results := make([]Submission, 0) + results := make([]model.Submission, 0) for _, id := range s.orderedIDs { record := s.records[id] if record == nil { @@ -132,7 +133,7 @@ func (s *Store) SearchSubmissions(text, cpu string) []Submission { continue } - results = append(results, *cloneSubmission(record.submission)) + results = append(results, *model.CloneSubmission(record.submission)) } return results @@ -154,13 +155,12 @@ func (s *Store) loadIndex() error { return err } - var submission Submission + var submission model.Submission if err := json.Unmarshal(payload, &submission); err != nil { return fmt.Errorf("decode %q: %w", item.Key(), err) } - indexed := newIndexedSubmission(&submission) - s.records[submission.SubmissionID] = indexed + s.records[submission.SubmissionID] = newIndexedSubmission(&submission) s.orderedIDs = append(s.orderedIDs, submission.SubmissionID) } @@ -168,22 +168,21 @@ func (s *Store) loadIndex() error { }) } -func newIndexedSubmission(submission *Submission) *indexedSubmission { +func newIndexedSubmission(submission *model.Submission) *indexedSubmission { return &indexedSubmission{ - submission: cloneSubmission(submission), - searchText: buildSearchText(submission), - cpuText: normalizeSearchText(submission.CPUInfo.BrandString), - submittedAt: submission.SubmittedAt, + submission: model.CloneSubmission(submission), + searchText: buildSearchText(submission), + cpuText: normalizeSearchText(submission.CPUInfo.BrandString), } } -func buildSearchText(submission *Submission) string { +func buildSearchText(submission *model.Submission) string { parts := []string{ submission.SubmissionID, submission.Submitter, submission.CPUInfo.BrandString, submission.CPUInfo.VendorID, - threadModeLabel(submission.Config.MultiCore), + model.ThreadModeLabel(submission.Config.MultiCore), strconv.Itoa(submission.Config.DurationSecs), strconv.Itoa(submission.CPUInfo.PhysicalCores), strconv.Itoa(submission.CPUInfo.LogicalCores), @@ -232,23 +231,54 @@ func matchesSearch(target, query string) bool { return true } -func cloneSubmission(submission *Submission) *Submission { - if submission == nil { - return nil +func pageBounds(page, pageSize, total int) (int, int, int) { + if pageSize <= 0 { + pageSize = 50 } - copySubmission := *submission - if len(submission.CoreResults) > 0 { - copySubmission.CoreResults = append([]CoreResult(nil), submission.CoreResults...) + totalPages := totalPages(total, pageSize) + if totalPages == 0 { + return 0, 0, 1 } - if len(submission.CPUInfo.Cores) > 0 { - copySubmission.CPUInfo.Cores = append([]CPUCoreDescriptor(nil), submission.CPUInfo.Cores...) + if page < 1 { + page = 1 } - if len(submission.CPUInfo.SupportedFeatures) > 0 { - copySubmission.CPUInfo.SupportedFeatures = append([]string(nil), submission.CPUInfo.SupportedFeatures...) + if page > totalPages { + page = totalPages } - return ©Submission + start := (page - 1) * pageSize + end := min(total, start+pageSize) + return start, end, page +} + +func totalPages(total, pageSize int) int { + if total == 0 || pageSize <= 0 { + return 0 + } + + pages := total / pageSize + if total%pageSize != 0 { + pages++ + } + + return pages +} + +func min(a, b int) int { + if a < b { + return a + } + + return b +} + +func max(a, b int) int { + if a > b { + return a + } + + return b } diff --git a/handlers.go b/lib/web/app.go similarity index 76% rename from handlers.go rename to lib/web/app.go index 663bdb8..380b692 100644 --- a/handlers.go +++ b/lib/web/app.go @@ -1,4 +1,4 @@ -package main +package web import ( "encoding/json" @@ -12,6 +12,9 @@ import ( "strings" "time" + "cpu-benchmark-server/lib/model" + "cpu-benchmark-server/lib/store" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) @@ -19,13 +22,13 @@ import ( const maxSubmissionBytes = 4 << 20 type App struct { - store *Store + store *store.Store templates *template.Template pageSize int } type indexPageData struct { - Submissions []Submission + Submissions []model.Submission QueryText string QueryCPU string Page int @@ -35,31 +38,30 @@ type indexPageData struct { ShowingTo int PrevURL string NextURL string - SearchMode bool } type jsonSubmissionEnvelope struct { - Submitter string `json:"submitter"` - Benchmark *BenchmarkResult `json:"benchmark"` - Result *BenchmarkResult `json:"result"` - Data *BenchmarkResult `json:"data"` + Submitter string `json:"submitter"` + Benchmark *model.BenchmarkResult `json:"benchmark"` + Result *model.BenchmarkResult `json:"result"` + Data *model.BenchmarkResult `json:"data"` } type flatSubmissionEnvelope struct { Submitter string `json:"submitter"` - BenchmarkResult + model.BenchmarkResult } type errorResponse struct { Error string `json:"error"` } -func NewApp(store *Store, pageSize int) (*App, error) { +func New(store *store.Store, pageSize int) (*App, error) { funcs := template.FuncMap{ "formatInt64": formatInt64, "formatFloat": formatFloat, "formatTime": formatTime, - "modeLabel": threadModeLabel, + "modeLabel": model.ThreadModeLabel, } templates, err := template.New("index.html").Funcs(funcs).ParseFiles("templates/index.html") @@ -95,13 +97,12 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { text := strings.TrimSpace(r.URL.Query().Get("text")) cpu := strings.TrimSpace(r.URL.Query().Get("cpu")) - searchMode := text != "" || cpu != "" var ( - submissions []Submission + submissions []model.Submission totalCount int ) - if searchMode { + if text != "" || cpu != "" { matches := a.store.SearchSubmissions(text, cpu) totalCount = len(matches) start, end, normalizedPage := pageBounds(page, a.pageSize, totalCount) @@ -114,26 +115,20 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { _, _, page = pageBounds(page, a.pageSize, totalCount) } - totalPages := totalPages(totalCount, a.pageSize) - showingFrom := 0 - showingTo := 0 - if totalCount > 0 && len(submissions) > 0 { - showingFrom = (page-1)*a.pageSize + 1 - showingTo = showingFrom + len(submissions) - 1 - } + totalPageCount := totalPages(totalCount, a.pageSize) + showingFrom, showingTo := visibleBounds(page, a.pageSize, len(submissions), totalCount) data := indexPageData{ Submissions: submissions, QueryText: text, QueryCPU: cpu, Page: page, - TotalPages: totalPages, + TotalPages: totalPageCount, TotalCount: totalCount, ShowingFrom: showingFrom, ShowingTo: showingTo, PrevURL: buildIndexURL(max(1, page-1), text, cpu), - NextURL: buildIndexURL(min(totalPages, page+1), text, cpu), - SearchMode: searchMode, + NextURL: buildIndexURL(max(1, min(totalPageCount, page+1)), text, cpu), } if err := a.templates.ExecuteTemplate(w, "index.html", data); err != nil { @@ -141,7 +136,7 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { } } -func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) { +func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "submissions": a.store.Count(), @@ -149,9 +144,7 @@ func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) { } func (a *App) handleSearch(w http.ResponseWriter, r *http.Request) { - text := r.URL.Query().Get("text") - cpu := r.URL.Query().Get("cpu") - results := a.store.SearchSubmissions(text, cpu) + results := a.store.SearchSubmissions(r.URL.Query().Get("text"), r.URL.Query().Get("cpu")) writeJSON(w, http.StatusOK, results) } @@ -183,11 +176,11 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { }) } -func parseSubmissionRequest(r *http.Request) (BenchmarkResult, string, error) { +func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, error) { contentType := r.Header.Get("Content-Type") mediaType, _, err := mime.ParseMediaType(contentType) if err != nil && contentType != "" { - return BenchmarkResult{}, "", fmt.Errorf("parse content type: %w", err) + return model.BenchmarkResult{}, "", fmt.Errorf("parse content type: %w", err) } switch mediaType { @@ -196,14 +189,14 @@ func parseSubmissionRequest(r *http.Request) (BenchmarkResult, string, error) { case "multipart/form-data": return parseMultipartSubmission(r) default: - return BenchmarkResult{}, "", fmt.Errorf("unsupported content type %q", mediaType) + return model.BenchmarkResult{}, "", fmt.Errorf("unsupported content type %q", mediaType) } } -func parseJSONSubmission(r *http.Request) (BenchmarkResult, string, error) { +func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, error) { body, err := io.ReadAll(r.Body) if err != nil { - return BenchmarkResult{}, "", fmt.Errorf("read request body: %w", err) + return model.BenchmarkResult{}, "", fmt.Errorf("read request body: %w", err) } submitter := firstNonEmpty( @@ -214,7 +207,7 @@ func parseJSONSubmission(r *http.Request) (BenchmarkResult, string, error) { var nested jsonSubmissionEnvelope if err := json.Unmarshal(body, &nested); err == nil { submitter = firstNonEmpty(nested.Submitter, submitter) - for _, candidate := range []*BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} { + for _, candidate := range []*model.BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} { if candidate != nil { return *candidate, submitter, nil } @@ -223,35 +216,32 @@ func parseJSONSubmission(r *http.Request) (BenchmarkResult, string, error) { var flat flatSubmissionEnvelope if err := json.Unmarshal(body, &flat); err != nil { - return BenchmarkResult{}, "", fmt.Errorf("decode benchmark JSON: %w", err) + return model.BenchmarkResult{}, "", fmt.Errorf("decode benchmark JSON: %w", err) } - submitter = firstNonEmpty(flat.Submitter, submitter) - return flat.BenchmarkResult, submitter, nil + return flat.BenchmarkResult, firstNonEmpty(flat.Submitter, submitter), nil } -func parseMultipartSubmission(r *http.Request) (BenchmarkResult, string, error) { +func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, error) { if err := r.ParseMultipartForm(maxSubmissionBytes); err != nil { - return BenchmarkResult{}, "", fmt.Errorf("parse multipart form: %w", err) + return model.BenchmarkResult{}, "", fmt.Errorf("parse multipart form: %w", err) } - submitter := r.FormValue("submitter") payload, err := readMultipartPayload(r) if err != nil { - return BenchmarkResult{}, "", err + return model.BenchmarkResult{}, "", err } - var result BenchmarkResult + var result model.BenchmarkResult if err := json.Unmarshal(payload, &result); err != nil { - return BenchmarkResult{}, "", fmt.Errorf("decode benchmark JSON: %w", err) + return model.BenchmarkResult{}, "", fmt.Errorf("decode benchmark JSON: %w", err) } - return result, submitter, nil + return result, r.FormValue("submitter"), nil } func readMultipartPayload(r *http.Request) ([]byte, error) { - fileFields := []string{"benchmark", "file", "benchmarkFile"} - for _, field := range fileFields { + for _, field := range []string{"benchmark", "file", "benchmarkFile"} { file, _, err := r.FormFile(field) if err == nil { defer file.Close() @@ -268,8 +258,7 @@ func readMultipartPayload(r *http.Request) ([]byte, error) { } } - textFields := []string{"benchmark", "payload", "result", "data"} - for _, field := range textFields { + for _, field := range []string{"benchmark", "payload", "result", "data"} { if value := strings.TrimSpace(r.FormValue(field)); value != "" { return []byte(value), nil } @@ -327,6 +316,16 @@ func totalPages(total, pageSize int) int { return pages } +func visibleBounds(page, pageSize, visibleCount, total int) (int, int) { + if total == 0 || visibleCount == 0 { + return 0, 0 + } + + from := (page-1)*pageSize + 1 + to := from + visibleCount - 1 + return from, to +} + func buildIndexURL(page int, text, cpu string) string { if page < 1 { page = 1 diff --git a/main.go b/main.go index 650cc88..0f0e568 100644 --- a/main.go +++ b/main.go @@ -2,24 +2,19 @@ package main import ( "context" + "cpu-benchmark-server/lib/config" + "cpu-benchmark-server/lib/store" + "cpu-benchmark-server/lib/web" "errors" "log" "net/http" "os" "os/signal" - "strconv" "sync" "syscall" "time" ) -type AppConfig struct { - Addr string - BadgerDir string - PageSize int - ShutdownTimeout time.Duration -} - func main() { logger := log.New(os.Stdout, "", log.LstdFlags|log.LUTC) if err := run(logger); err != nil { @@ -29,26 +24,26 @@ func main() { } func run(logger *log.Logger) error { - cfg := loadConfig() + cfg := config.Load() if err := os.MkdirAll(cfg.BadgerDir, 0o755); err != nil { return err } - store, err := OpenStore(cfg.BadgerDir) + benchmarkStore, err := store.Open(cfg.BadgerDir) if err != nil { return err } var closeOnce sync.Once closeStore := func() { - if err := store.Close(); err != nil { + if err := benchmarkStore.Close(); err != nil { logger.Printf("close store: %v", err) } } defer closeOnce.Do(closeStore) - app, err := NewApp(store, cfg.PageSize) + app, err := web.New(benchmarkStore, cfg.PageSize) if err != nil { return err } @@ -88,48 +83,3 @@ func run(logger *log.Logger) error { return nil } - -func loadConfig() AppConfig { - return AppConfig{ - Addr: envOrDefault("APP_ADDR", ":8080"), - BadgerDir: envOrDefault("BADGER_DIR", "data/badger"), - PageSize: envIntOrDefault("PAGE_SIZE", 50), - ShutdownTimeout: envDurationOrDefault("SHUTDOWN_TIMEOUT", 10*time.Second), - } -} - -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - - return fallback -} - -func envIntOrDefault(key string, fallback int) int { - value := os.Getenv(key) - if value == "" { - return fallback - } - - parsed, err := strconv.Atoi(value) - if err != nil || parsed <= 0 { - return fallback - } - - return parsed -} - -func envDurationOrDefault(key string, fallback time.Duration) time.Duration { - value := os.Getenv(key) - if value == "" { - return fallback - } - - parsed, err := time.ParseDuration(value) - if err != nil || parsed <= 0 { - return fallback - } - - return parsed -}