diff --git a/README.md b/README.md index d36210f..533205a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Production-oriented Go web application for ingesting CPU benchmark results, stor ## Features - `POST /api/submit` accepts either `application/json` or `multipart/form-data`. -- `GET /api/search` performs case-insensitive token matching against submitter/general fields and CPU brand strings. +- `GET /api/search` performs case-insensitive token matching against submitter/general fields and CPU brand strings, with explicit thread-mode, platform, intensity, and duration filters. - `GET /` renders the latest submissions with search and pagination. - BadgerDB stores each submission under a reverse-timestamp key so native iteration returns newest records first. - A startup-loaded in-memory search index prevents full DB deserialization for every query. @@ -17,6 +17,7 @@ Each stored submission contains: - `submissionID`: server-generated UUID - `submitter`: defaults to `Anonymous` if omitted +- `platform`: normalized to `windows`, `linux`, or `macos`; defaults to `windows` if omitted - `submittedAt`: server-side storage timestamp - Benchmark payload fields: - `config` @@ -84,15 +85,21 @@ Accepted content types: JSON requests support either: -1. A wrapper envelope with `submitter` and nested `benchmark` +1. A wrapper envelope with `submitter`, `platform`, and nested `benchmark` 2. A raw benchmark JSON body, with optional submitter provided via: - query string `?submitter=...` - header `X-Submitter` - top-level `submitter` field + - query string `?platform=...` + - header `X-Platform` + - top-level `platform` field + +`platform` is stored for every submission. Supported values are `windows`, `linux`, and `macos`. If the client does not send it, the server defaults to `windows`. Multipart requests support: - `submitter` text field +- `platform` text field - benchmark JSON as one of these file fields: `benchmark`, `file`, `benchmarkFile` - or benchmark JSON as text fields: `benchmark`, `payload`, `result`, `data` @@ -102,6 +109,7 @@ Example success response: { "success": true, "submissionID": "8f19d442-1be0-4989-97cf-3f8ee6b61548", + "platform": "windows", "submitter": "Workstation-Lab-A", "submittedAt": "2026-04-15T15:45:41.327225Z" } @@ -113,11 +121,15 @@ Query parameters: - `text`: token-matches submitter and general searchable fields - `cpu`: token-matches `cpuInfo.brandString` +- `thread`: `single` or `multi` +- `platform`: `windows`, `linux`, or `macos` +- `intensity`: exact match on `config.intensity` +- `durationSecs`: exact match on `config.durationSecs` Example: ```bash -curl "http://localhost:8080/api/search?text=intel&cpu=13700" +curl "http://localhost:8080/api/search?text=intel&cpu=13700&thread=multi&platform=windows&intensity=10&durationSecs=30" ``` ### `GET /` @@ -127,13 +139,17 @@ Query parameters: - `page` - `text` - `cpu` +- `thread` +- `platform` +- `intensity` +- `durationSecs` Examples: ```text http://localhost:8080/ http://localhost:8080/?page=2 -http://localhost:8080/?text=anonymous&cpu=ryzen +http://localhost:8080/?text=anonymous&cpu=ryzen&thread=multi&platform=windows&intensity=10&durationSecs=20 ``` ## Request Examples @@ -149,6 +165,7 @@ You can also submit one of the provided sample payloads directly: ```bash curl -X POST "http://localhost:8080/api/submit?submitter=Example-CLI" \ -H "Content-Type: application/json" \ + -H "X-Platform: windows" \ --data-binary @example_jsons/5800X/cpu-bench-result.json ``` @@ -157,6 +174,7 @@ Or as multipart: ```bash curl -X POST "http://localhost:8080/api/submit" \ -F "submitter=Example-Multipart" \ + -F "platform=linux" \ -F "benchmark=@example_jsons/i9/cpu-bench-result.json;type=application/json" ``` @@ -168,7 +186,7 @@ curl -X POST "http://localhost:8080/api/submit" \ - canonical submission payload - normalized general search text - normalized CPU brand text -- Searches scan the in-memory ordered slice rather than reopening and deserializing Badger values for every request. +- Searches scan the in-memory ordered slice rather than reopening and deserializing Badger values for every request, and apply explicit platform, thread-mode, intensity, and duration filters in memory. ## Docker diff --git a/docker-compose.yml b/docker-compose.yml index db9413b..4ad4e28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,5 @@ services: PAGE_SIZE: "50" SHUTDOWN_TIMEOUT: 10s volumes: - - badger-data:/data - restart: unless-stopped - -volumes: - badger-data: + - ./badger-data:/data + restart: unless-stopped \ No newline at end of file diff --git a/http/search.http b/http/search.http index 10719b5..465cc6f 100644 --- a/http/search.http +++ b/http/search.http @@ -1,9 +1,9 @@ -GET http://localhost:8080/api/search?text=intel&cpu=13700 +GET http://localhost:8080/api/search?text=intel&cpu=13700&thread=multi&platform=windows&intensity=10&durationSecs=30 ### -GET http://localhost:8080/api/search?text=anonymous +GET http://localhost:8080/api/search?text=anonymous&thread=single&platform=linux&intensity=1&durationSecs=10 ### -GET http://localhost:8080/?page=1&text=lab&cpu=ryzen +GET http://localhost:8080/?page=1&text=lab&cpu=ryzen&thread=multi&platform=windows&intensity=10&durationSecs=20 diff --git a/http/submit-json.http b/http/submit-json.http index a168123..624bec0 100644 --- a/http/submit-json.http +++ b/http/submit-json.http @@ -3,6 +3,7 @@ Content-Type: application/json { "submitter": "Workstation-Lab-A", + "platform": "windows", "benchmark": { "config": { "durationSecs": 20, diff --git a/http/submit-multipart.http b/http/submit-multipart.http index d1ecbed..bc505a6 100644 --- a/http/submit-multipart.http +++ b/http/submit-multipart.http @@ -6,6 +6,10 @@ Content-Disposition: form-data; name="submitter" Intel-Test-Rig --BenchBoundary +Content-Disposition: form-data; name="platform" + +linux +--BenchBoundary Content-Disposition: form-data; name="benchmark"; filename="cpu-bench-result.json" Content-Type: application/json diff --git a/lib/model/submission.go b/lib/model/submission.go index 130a101..32d6cf2 100644 --- a/lib/model/submission.go +++ b/lib/model/submission.go @@ -60,6 +60,7 @@ type BenchmarkResult struct { type Submission struct { SubmissionID string `json:"submissionID"` Submitter string `json:"submitter"` + Platform string `json:"platform"` SubmittedAt time.Time `json:"submittedAt"` BenchmarkResult } @@ -111,6 +112,27 @@ func NormalizeSubmitter(submitter string) string { return submitter } +func NormalizePlatform(platform string) string { + switch strings.ToLower(strings.TrimSpace(platform)) { + case "", "windows": + return "windows" + case "linux": + return "linux" + case "macos": + return "macos" + default: + return "" + } +} + +func ValidatePlatform(platform string) error { + if NormalizePlatform(platform) == "" { + return errors.New("platform must be one of windows, linux, or macos") + } + + return nil +} + func ThreadModeLabel(multiCore bool) string { if multiCore { return "Multi-threaded" diff --git a/lib/store/store.go b/lib/store/store.go index c6cf185..abe5745 100644 --- a/lib/store/store.go +++ b/lib/store/store.go @@ -21,6 +21,10 @@ type indexedSubmission struct { submission *model.Submission searchText string cpuText string + platform string + threadMode string + intensity int + duration int } type Store struct { @@ -61,10 +65,11 @@ func (s *Store) Count() int { return len(s.orderedIDs) } -func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter string) (*model.Submission, error) { +func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter, platform string) (*model.Submission, error) { submission := &model.Submission{ SubmissionID: uuid.NewString(), Submitter: model.NormalizeSubmitter(submitter), + Platform: model.NormalizePlatform(platform), SubmittedAt: time.Now().UTC(), BenchmarkResult: result, } @@ -111,9 +116,11 @@ func (s *Store) ListSubmissions(page, pageSize int) ([]model.Submission, int) { return results, total } -func (s *Store) SearchSubmissions(text, cpu string) []model.Submission { +func (s *Store) SearchSubmissions(text, cpu, thread, platform string, intensity, durationSecs int) []model.Submission { queryText := normalizeSearchText(text) cpuText := normalizeSearchText(cpu) + thread = normalizeThreadFilter(thread) + platform = normalizePlatformFilter(platform) s.mu.RLock() defer s.mu.RUnlock() @@ -133,6 +140,22 @@ func (s *Store) SearchSubmissions(text, cpu string) []model.Submission { continue } + if thread != "" && record.threadMode != thread { + continue + } + + if platform != "" && record.platform != platform { + continue + } + + if intensity > 0 && record.intensity != intensity { + continue + } + + if durationSecs > 0 && record.duration != durationSecs { + continue + } + results = append(results, *model.CloneSubmission(record.submission)) } @@ -173,6 +196,10 @@ func newIndexedSubmission(submission *model.Submission) *indexedSubmission { submission: model.CloneSubmission(submission), searchText: buildSearchText(submission), cpuText: normalizeSearchText(submission.CPUInfo.BrandString), + platform: model.NormalizePlatform(submission.Platform), + threadMode: normalizeThreadMode(submission.Config.MultiCore), + intensity: submission.Config.Intensity, + duration: submission.Config.DurationSecs, } } @@ -180,10 +207,12 @@ func buildSearchText(submission *model.Submission) string { parts := []string{ submission.SubmissionID, submission.Submitter, + submission.Platform, submission.CPUInfo.BrandString, submission.CPUInfo.VendorID, model.ThreadModeLabel(submission.Config.MultiCore), strconv.Itoa(submission.Config.DurationSecs), + strconv.Itoa(submission.Config.Intensity), strconv.Itoa(submission.CPUInfo.PhysicalCores), strconv.Itoa(submission.CPUInfo.LogicalCores), strconv.FormatInt(submission.Duration, 10), @@ -231,6 +260,42 @@ func matchesSearch(target, query string) bool { return true } +func normalizeThreadFilter(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case "single": + return "single" + case "multi": + return "multi" + default: + return "" + } +} + +func normalizeThreadMode(multiCore bool) string { + if multiCore { + return "multi" + } + + return "single" +} + +func normalizePlatformFilter(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return "" + case "windows": + return "windows" + case "linux": + return "linux" + case "macos": + return "macos" + default: + return "" + } +} + func pageBounds(page, pageSize, total int) (int, int, int) { if pageSize <= 0 { pageSize = 50 diff --git a/lib/web/app.go b/lib/web/app.go index 380b692..f20f9e3 100644 --- a/lib/web/app.go +++ b/lib/web/app.go @@ -28,20 +28,25 @@ type App struct { } type indexPageData struct { - Submissions []model.Submission - QueryText string - QueryCPU string - Page int - TotalPages int - TotalCount int - ShowingFrom int - ShowingTo int - PrevURL string - NextURL string + Submissions []model.Submission + QueryText string + QueryCPU string + QueryThread string + QueryPlatform string + QueryIntensity int + QueryDuration int + Page int + TotalPages int + TotalCount int + ShowingFrom int + ShowingTo int + PrevURL string + NextURL string } type jsonSubmissionEnvelope struct { Submitter string `json:"submitter"` + Platform string `json:"platform"` Benchmark *model.BenchmarkResult `json:"benchmark"` Result *model.BenchmarkResult `json:"result"` Data *model.BenchmarkResult `json:"data"` @@ -49,6 +54,7 @@ type jsonSubmissionEnvelope struct { type flatSubmissionEnvelope struct { Submitter string `json:"submitter"` + Platform string `json:"platform"` model.BenchmarkResult } @@ -96,14 +102,18 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { page := parsePositiveInt(r.URL.Query().Get("page"), 1) text := strings.TrimSpace(r.URL.Query().Get("text")) cpu := strings.TrimSpace(r.URL.Query().Get("cpu")) + thread := strings.TrimSpace(r.URL.Query().Get("thread")) + platform := strings.TrimSpace(r.URL.Query().Get("platform")) + intensity := parsePositiveInt(r.URL.Query().Get("intensity"), 0) + durationSecs := parsePositiveInt(r.URL.Query().Get("durationSecs"), 0) var ( submissions []model.Submission totalCount int ) - if text != "" || cpu != "" { - matches := a.store.SearchSubmissions(text, cpu) + if text != "" || cpu != "" || thread != "" || platform != "" || intensity > 0 || durationSecs > 0 { + matches := a.store.SearchSubmissions(text, cpu, thread, platform, intensity, durationSecs) totalCount = len(matches) start, end, normalizedPage := pageBounds(page, a.pageSize, totalCount) page = normalizedPage @@ -119,16 +129,20 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { showingFrom, showingTo := visibleBounds(page, a.pageSize, len(submissions), totalCount) data := indexPageData{ - Submissions: submissions, - QueryText: text, - QueryCPU: cpu, - Page: page, - TotalPages: totalPageCount, - TotalCount: totalCount, - ShowingFrom: showingFrom, - ShowingTo: showingTo, - PrevURL: buildIndexURL(max(1, page-1), text, cpu), - NextURL: buildIndexURL(max(1, min(totalPageCount, page+1)), text, cpu), + Submissions: submissions, + QueryText: text, + QueryCPU: cpu, + QueryThread: normalizeThreadFilter(thread), + QueryPlatform: normalizePlatformFilter(platform), + QueryIntensity: intensity, + QueryDuration: durationSecs, + Page: page, + TotalPages: totalPageCount, + TotalCount: totalCount, + ShowingFrom: showingFrom, + ShowingTo: showingTo, + PrevURL: buildIndexURL(max(1, page-1), text, cpu, thread, platform, intensity, durationSecs), + NextURL: buildIndexURL(max(1, min(totalPageCount, page+1)), text, cpu, thread, platform, intensity, durationSecs), } if err := a.templates.ExecuteTemplate(w, "index.html", data); err != nil { @@ -144,14 +158,21 @@ func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) { } func (a *App) handleSearch(w http.ResponseWriter, r *http.Request) { - results := a.store.SearchSubmissions(r.URL.Query().Get("text"), r.URL.Query().Get("cpu")) + results := a.store.SearchSubmissions( + r.URL.Query().Get("text"), + r.URL.Query().Get("cpu"), + r.URL.Query().Get("thread"), + r.URL.Query().Get("platform"), + parsePositiveInt(r.URL.Query().Get("intensity"), 0), + parsePositiveInt(r.URL.Query().Get("durationSecs"), 0), + ) writeJSON(w, http.StatusOK, results) } func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxSubmissionBytes) - result, submitter, err := parseSubmissionRequest(r) + result, submitter, platform, err := parseSubmissionRequest(r) if err != nil { writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) return @@ -162,7 +183,13 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { return } - submission, err := a.store.SaveSubmission(result, submitter) + platform = model.NormalizePlatform(platform) + if err := model.ValidatePlatform(platform); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) + return + } + + submission, err := a.store.SaveSubmission(result, submitter, platform) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{Error: fmt.Sprintf("store submission: %v", err)}) return @@ -172,15 +199,16 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { "success": true, "submissionID": submission.SubmissionID, "submitter": submission.Submitter, + "platform": submission.Platform, "submittedAt": submission.SubmittedAt, }) } -func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, error) { +func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, string, error) { contentType := r.Header.Get("Content-Type") mediaType, _, err := mime.ParseMediaType(contentType) if err != nil && contentType != "" { - return model.BenchmarkResult{}, "", fmt.Errorf("parse content type: %w", err) + return model.BenchmarkResult{}, "", "", fmt.Errorf("parse content type: %w", err) } switch mediaType { @@ -189,55 +217,60 @@ func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, err case "multipart/form-data": return parseMultipartSubmission(r) default: - return model.BenchmarkResult{}, "", fmt.Errorf("unsupported content type %q", mediaType) + return model.BenchmarkResult{}, "", "", fmt.Errorf("unsupported content type %q", mediaType) } } -func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, error) { +func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, string, error) { body, err := io.ReadAll(r.Body) if err != nil { - return model.BenchmarkResult{}, "", fmt.Errorf("read request body: %w", err) + return model.BenchmarkResult{}, "", "", fmt.Errorf("read request body: %w", err) } submitter := firstNonEmpty( r.URL.Query().Get("submitter"), r.Header.Get("X-Submitter"), ) + platform := firstNonEmpty( + r.URL.Query().Get("platform"), + r.Header.Get("X-Platform"), + ) var nested jsonSubmissionEnvelope if err := json.Unmarshal(body, &nested); err == nil { submitter = firstNonEmpty(nested.Submitter, submitter) + platform = firstNonEmpty(nested.Platform, platform) for _, candidate := range []*model.BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} { if candidate != nil { - return *candidate, submitter, nil + return *candidate, submitter, platform, nil } } } var flat flatSubmissionEnvelope if err := json.Unmarshal(body, &flat); err != nil { - return model.BenchmarkResult{}, "", fmt.Errorf("decode benchmark JSON: %w", err) + return model.BenchmarkResult{}, "", "", fmt.Errorf("decode benchmark JSON: %w", err) } - return flat.BenchmarkResult, firstNonEmpty(flat.Submitter, submitter), nil + return flat.BenchmarkResult, firstNonEmpty(flat.Submitter, submitter), firstNonEmpty(flat.Platform, platform, "windows"), nil } -func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, error) { +func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, string, error) { if err := r.ParseMultipartForm(maxSubmissionBytes); err != nil { - return model.BenchmarkResult{}, "", fmt.Errorf("parse multipart form: %w", err) + return model.BenchmarkResult{}, "", "", fmt.Errorf("parse multipart form: %w", err) } payload, err := readMultipartPayload(r) if err != nil { - return model.BenchmarkResult{}, "", err + return model.BenchmarkResult{}, "", "", err } var result model.BenchmarkResult if err := json.Unmarshal(payload, &result); err != nil { - return model.BenchmarkResult{}, "", fmt.Errorf("decode benchmark JSON: %w", err) + return model.BenchmarkResult{}, "", "", fmt.Errorf("decode benchmark JSON: %w", err) } - return result, r.FormValue("submitter"), nil + return result, r.FormValue("submitter"), firstNonEmpty(r.FormValue("platform"), "windows"), nil } func readMultipartPayload(r *http.Request) ([]byte, error) { @@ -326,7 +359,7 @@ func visibleBounds(page, pageSize, visibleCount, total int) (int, int) { return from, to } -func buildIndexURL(page int, text, cpu string) string { +func buildIndexURL(page int, text, cpu, thread, platform string, intensity, durationSecs int) string { if page < 1 { page = 1 } @@ -339,6 +372,18 @@ func buildIndexURL(page int, text, cpu string) string { if strings.TrimSpace(cpu) != "" { values.Set("cpu", cpu) } + if normalizedThread := normalizeThreadFilter(thread); normalizedThread != "" { + values.Set("thread", normalizedThread) + } + if normalizedPlatform := normalizePlatformFilter(platform); normalizedPlatform != "" { + values.Set("platform", normalizedPlatform) + } + if intensity > 0 { + values.Set("intensity", strconv.Itoa(intensity)) + } + if durationSecs > 0 { + values.Set("durationSecs", strconv.Itoa(durationSecs)) + } return "/?" + values.Encode() } @@ -361,6 +406,30 @@ func firstNonEmpty(values ...string) string { return "" } +func normalizeThreadFilter(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "single": + return "single" + case "multi": + return "multi" + default: + return "" + } +} + +func normalizePlatformFilter(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "windows": + return "windows" + case "linux": + return "linux" + case "macos": + return "macos" + default: + return "" + } +} + func formatInt64(value int64) string { negative := value < 0 if negative { diff --git a/templates/index.html b/templates/index.html index 23fae1b..96bd81a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12,7 +12,7 @@

CPU Benchmark Platform

-

Submission browser and API for local CPU benchmark runs

+

Simple CPU Benchmark Server

Browse recent benchmark submissions, filter by submitter or CPU brand, and inspect per-core throughput details.

@@ -31,7 +31,7 @@
-
+ + + + +