feat(search): support platform and benchmark config filters
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 1m2s

Add platform handling to submissions and persist a normalized value (`windows`, `linux`, `macos`) with a default of `windows` when omitted.

Extend search/index filtering to support `thread`, `platform`, `intensity`, and `durationSecs` alongside existing text/CPU token matching, and wire these params through request parsing, page data, and navigation URLs.

Update API/README docs and examples to reflect the new submission inputs and search capabilities so users can run more precise queries.feat(search): support platform and benchmark config filters

Add platform handling to submissions and persist a normalized value (`windows`, `linux`, `macos`) with a default of `windows` when omitted.

Extend search/index filtering to support `thread`, `platform`, `intensity`, and `durationSecs` alongside existing text/CPU token matching, and wire these params through request parsing, page data, and navigation URLs.

Update API/README docs and examples to reflect the new submission inputs and search capabilities so users can run more precise queries.
This commit is contained in:
2026-04-15 20:23:37 +03:00
parent f21728e1ef
commit 64e3c1966d
9 changed files with 309 additions and 60 deletions

View File

@@ -5,7 +5,7 @@ Production-oriented Go web application for ingesting CPU benchmark results, stor
## Features ## Features
- `POST /api/submit` accepts either `application/json` or `multipart/form-data`. - `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. - `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. - 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. - 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 - `submissionID`: server-generated UUID
- `submitter`: defaults to `Anonymous` if omitted - `submitter`: defaults to `Anonymous` if omitted
- `platform`: normalized to `windows`, `linux`, or `macos`; defaults to `windows` if omitted
- `submittedAt`: server-side storage timestamp - `submittedAt`: server-side storage timestamp
- Benchmark payload fields: - Benchmark payload fields:
- `config` - `config`
@@ -84,15 +85,21 @@ Accepted content types:
JSON requests support either: 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: 2. A raw benchmark JSON body, with optional submitter provided via:
- query string `?submitter=...` - query string `?submitter=...`
- header `X-Submitter` - header `X-Submitter`
- top-level `submitter` field - 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: Multipart requests support:
- `submitter` text field - `submitter` text field
- `platform` text field
- benchmark JSON as one of these file fields: `benchmark`, `file`, `benchmarkFile` - benchmark JSON as one of these file fields: `benchmark`, `file`, `benchmarkFile`
- or benchmark JSON as text fields: `benchmark`, `payload`, `result`, `data` - or benchmark JSON as text fields: `benchmark`, `payload`, `result`, `data`
@@ -102,6 +109,7 @@ Example success response:
{ {
"success": true, "success": true,
"submissionID": "8f19d442-1be0-4989-97cf-3f8ee6b61548", "submissionID": "8f19d442-1be0-4989-97cf-3f8ee6b61548",
"platform": "windows",
"submitter": "Workstation-Lab-A", "submitter": "Workstation-Lab-A",
"submittedAt": "2026-04-15T15:45:41.327225Z" "submittedAt": "2026-04-15T15:45:41.327225Z"
} }
@@ -113,11 +121,15 @@ Query parameters:
- `text`: token-matches submitter and general searchable fields - `text`: token-matches submitter and general searchable fields
- `cpu`: token-matches `cpuInfo.brandString` - `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: Example:
```bash ```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 /` ### `GET /`
@@ -127,13 +139,17 @@ Query parameters:
- `page` - `page`
- `text` - `text`
- `cpu` - `cpu`
- `thread`
- `platform`
- `intensity`
- `durationSecs`
Examples: Examples:
```text ```text
http://localhost:8080/ http://localhost:8080/
http://localhost:8080/?page=2 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 ## Request Examples
@@ -149,6 +165,7 @@ You can also submit one of the provided sample payloads directly:
```bash ```bash
curl -X POST "http://localhost:8080/api/submit?submitter=Example-CLI" \ curl -X POST "http://localhost:8080/api/submit?submitter=Example-CLI" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-Platform: windows" \
--data-binary @example_jsons/5800X/cpu-bench-result.json --data-binary @example_jsons/5800X/cpu-bench-result.json
``` ```
@@ -157,6 +174,7 @@ Or as multipart:
```bash ```bash
curl -X POST "http://localhost:8080/api/submit" \ curl -X POST "http://localhost:8080/api/submit" \
-F "submitter=Example-Multipart" \ -F "submitter=Example-Multipart" \
-F "platform=linux" \
-F "benchmark=@example_jsons/i9/cpu-bench-result.json;type=application/json" -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 - canonical submission payload
- normalized general search text - normalized general search text
- normalized CPU brand 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 ## Docker

View File

@@ -12,8 +12,5 @@ services:
PAGE_SIZE: "50" PAGE_SIZE: "50"
SHUTDOWN_TIMEOUT: 10s SHUTDOWN_TIMEOUT: 10s
volumes: volumes:
- badger-data:/data - ./badger-data:/data
restart: unless-stopped restart: unless-stopped
volumes:
badger-data:

View File

@@ -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

View File

@@ -3,6 +3,7 @@ Content-Type: application/json
{ {
"submitter": "Workstation-Lab-A", "submitter": "Workstation-Lab-A",
"platform": "windows",
"benchmark": { "benchmark": {
"config": { "config": {
"durationSecs": 20, "durationSecs": 20,

View File

@@ -6,6 +6,10 @@ Content-Disposition: form-data; name="submitter"
Intel-Test-Rig Intel-Test-Rig
--BenchBoundary --BenchBoundary
Content-Disposition: form-data; name="platform"
linux
--BenchBoundary
Content-Disposition: form-data; name="benchmark"; filename="cpu-bench-result.json" Content-Disposition: form-data; name="benchmark"; filename="cpu-bench-result.json"
Content-Type: application/json Content-Type: application/json

View File

@@ -60,6 +60,7 @@ type BenchmarkResult struct {
type Submission struct { type Submission struct {
SubmissionID string `json:"submissionID"` SubmissionID string `json:"submissionID"`
Submitter string `json:"submitter"` Submitter string `json:"submitter"`
Platform string `json:"platform"`
SubmittedAt time.Time `json:"submittedAt"` SubmittedAt time.Time `json:"submittedAt"`
BenchmarkResult BenchmarkResult
} }
@@ -111,6 +112,27 @@ func NormalizeSubmitter(submitter string) string {
return submitter 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 { func ThreadModeLabel(multiCore bool) string {
if multiCore { if multiCore {
return "Multi-threaded" return "Multi-threaded"

View File

@@ -21,6 +21,10 @@ type indexedSubmission struct {
submission *model.Submission submission *model.Submission
searchText string searchText string
cpuText string cpuText string
platform string
threadMode string
intensity int
duration int
} }
type Store struct { type Store struct {
@@ -61,10 +65,11 @@ func (s *Store) Count() int {
return len(s.orderedIDs) 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{ submission := &model.Submission{
SubmissionID: uuid.NewString(), SubmissionID: uuid.NewString(),
Submitter: model.NormalizeSubmitter(submitter), Submitter: model.NormalizeSubmitter(submitter),
Platform: model.NormalizePlatform(platform),
SubmittedAt: time.Now().UTC(), SubmittedAt: time.Now().UTC(),
BenchmarkResult: result, BenchmarkResult: result,
} }
@@ -111,9 +116,11 @@ func (s *Store) ListSubmissions(page, pageSize int) ([]model.Submission, int) {
return results, total 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) queryText := normalizeSearchText(text)
cpuText := normalizeSearchText(cpu) cpuText := normalizeSearchText(cpu)
thread = normalizeThreadFilter(thread)
platform = normalizePlatformFilter(platform)
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -133,6 +140,22 @@ func (s *Store) SearchSubmissions(text, cpu string) []model.Submission {
continue 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)) results = append(results, *model.CloneSubmission(record.submission))
} }
@@ -173,6 +196,10 @@ func newIndexedSubmission(submission *model.Submission) *indexedSubmission {
submission: model.CloneSubmission(submission), submission: model.CloneSubmission(submission),
searchText: buildSearchText(submission), searchText: buildSearchText(submission),
cpuText: normalizeSearchText(submission.CPUInfo.BrandString), 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{ parts := []string{
submission.SubmissionID, submission.SubmissionID,
submission.Submitter, submission.Submitter,
submission.Platform,
submission.CPUInfo.BrandString, submission.CPUInfo.BrandString,
submission.CPUInfo.VendorID, submission.CPUInfo.VendorID,
model.ThreadModeLabel(submission.Config.MultiCore), model.ThreadModeLabel(submission.Config.MultiCore),
strconv.Itoa(submission.Config.DurationSecs), strconv.Itoa(submission.Config.DurationSecs),
strconv.Itoa(submission.Config.Intensity),
strconv.Itoa(submission.CPUInfo.PhysicalCores), strconv.Itoa(submission.CPUInfo.PhysicalCores),
strconv.Itoa(submission.CPUInfo.LogicalCores), strconv.Itoa(submission.CPUInfo.LogicalCores),
strconv.FormatInt(submission.Duration, 10), strconv.FormatInt(submission.Duration, 10),
@@ -231,6 +260,42 @@ func matchesSearch(target, query string) bool {
return true 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) { func pageBounds(page, pageSize, total int) (int, int, int) {
if pageSize <= 0 { if pageSize <= 0 {
pageSize = 50 pageSize = 50

View File

@@ -28,20 +28,25 @@ type App struct {
} }
type indexPageData struct { type indexPageData struct {
Submissions []model.Submission Submissions []model.Submission
QueryText string QueryText string
QueryCPU string QueryCPU string
Page int QueryThread string
TotalPages int QueryPlatform string
TotalCount int QueryIntensity int
ShowingFrom int QueryDuration int
ShowingTo int Page int
PrevURL string TotalPages int
NextURL string TotalCount int
ShowingFrom int
ShowingTo int
PrevURL string
NextURL string
} }
type jsonSubmissionEnvelope struct { type jsonSubmissionEnvelope struct {
Submitter string `json:"submitter"` Submitter string `json:"submitter"`
Platform string `json:"platform"`
Benchmark *model.BenchmarkResult `json:"benchmark"` Benchmark *model.BenchmarkResult `json:"benchmark"`
Result *model.BenchmarkResult `json:"result"` Result *model.BenchmarkResult `json:"result"`
Data *model.BenchmarkResult `json:"data"` Data *model.BenchmarkResult `json:"data"`
@@ -49,6 +54,7 @@ type jsonSubmissionEnvelope struct {
type flatSubmissionEnvelope struct { type flatSubmissionEnvelope struct {
Submitter string `json:"submitter"` Submitter string `json:"submitter"`
Platform string `json:"platform"`
model.BenchmarkResult model.BenchmarkResult
} }
@@ -96,14 +102,18 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
page := parsePositiveInt(r.URL.Query().Get("page"), 1) page := parsePositiveInt(r.URL.Query().Get("page"), 1)
text := strings.TrimSpace(r.URL.Query().Get("text")) text := strings.TrimSpace(r.URL.Query().Get("text"))
cpu := strings.TrimSpace(r.URL.Query().Get("cpu")) 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 ( var (
submissions []model.Submission submissions []model.Submission
totalCount int totalCount int
) )
if text != "" || cpu != "" { if text != "" || cpu != "" || thread != "" || platform != "" || intensity > 0 || durationSecs > 0 {
matches := a.store.SearchSubmissions(text, cpu) matches := a.store.SearchSubmissions(text, cpu, thread, platform, intensity, durationSecs)
totalCount = len(matches) totalCount = len(matches)
start, end, normalizedPage := pageBounds(page, a.pageSize, totalCount) start, end, normalizedPage := pageBounds(page, a.pageSize, totalCount)
page = normalizedPage 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) showingFrom, showingTo := visibleBounds(page, a.pageSize, len(submissions), totalCount)
data := indexPageData{ data := indexPageData{
Submissions: submissions, Submissions: submissions,
QueryText: text, QueryText: text,
QueryCPU: cpu, QueryCPU: cpu,
Page: page, QueryThread: normalizeThreadFilter(thread),
TotalPages: totalPageCount, QueryPlatform: normalizePlatformFilter(platform),
TotalCount: totalCount, QueryIntensity: intensity,
ShowingFrom: showingFrom, QueryDuration: durationSecs,
ShowingTo: showingTo, Page: page,
PrevURL: buildIndexURL(max(1, page-1), text, cpu), TotalPages: totalPageCount,
NextURL: buildIndexURL(max(1, min(totalPageCount, page+1)), text, cpu), 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 { 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) { 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) writeJSON(w, http.StatusOK, results)
} }
func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxSubmissionBytes) r.Body = http.MaxBytesReader(w, r.Body, maxSubmissionBytes)
result, submitter, err := parseSubmissionRequest(r) result, submitter, platform, err := parseSubmissionRequest(r)
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
return return
@@ -162,7 +183,13 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: fmt.Sprintf("store submission: %v", err)}) writeJSON(w, http.StatusInternalServerError, errorResponse{Error: fmt.Sprintf("store submission: %v", err)})
return return
@@ -172,15 +199,16 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
"success": true, "success": true,
"submissionID": submission.SubmissionID, "submissionID": submission.SubmissionID,
"submitter": submission.Submitter, "submitter": submission.Submitter,
"platform": submission.Platform,
"submittedAt": submission.SubmittedAt, "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") contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType) mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil && 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 { switch mediaType {
@@ -189,55 +217,60 @@ func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, err
case "multipart/form-data": case "multipart/form-data":
return parseMultipartSubmission(r) return parseMultipartSubmission(r)
default: 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) body, err := io.ReadAll(r.Body)
if err != nil { 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( submitter := firstNonEmpty(
r.URL.Query().Get("submitter"), r.URL.Query().Get("submitter"),
r.Header.Get("X-Submitter"), r.Header.Get("X-Submitter"),
) )
platform := firstNonEmpty(
r.URL.Query().Get("platform"),
r.Header.Get("X-Platform"),
)
var nested jsonSubmissionEnvelope var nested jsonSubmissionEnvelope
if err := json.Unmarshal(body, &nested); err == nil { if err := json.Unmarshal(body, &nested); err == nil {
submitter = firstNonEmpty(nested.Submitter, submitter) submitter = firstNonEmpty(nested.Submitter, submitter)
platform = firstNonEmpty(nested.Platform, platform)
for _, candidate := range []*model.BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} { for _, candidate := range []*model.BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} {
if candidate != nil { if candidate != nil {
return *candidate, submitter, nil return *candidate, submitter, platform, nil
} }
} }
} }
var flat flatSubmissionEnvelope var flat flatSubmissionEnvelope
if err := json.Unmarshal(body, &flat); err != nil { 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 { 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) payload, err := readMultipartPayload(r)
if err != nil { if err != nil {
return model.BenchmarkResult{}, "", err return model.BenchmarkResult{}, "", "", err
} }
var result model.BenchmarkResult var result model.BenchmarkResult
if err := json.Unmarshal(payload, &result); err != nil { 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) { func readMultipartPayload(r *http.Request) ([]byte, error) {
@@ -326,7 +359,7 @@ func visibleBounds(page, pageSize, visibleCount, total int) (int, int) {
return from, to 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 { if page < 1 {
page = 1 page = 1
} }
@@ -339,6 +372,18 @@ func buildIndexURL(page int, text, cpu string) string {
if strings.TrimSpace(cpu) != "" { if strings.TrimSpace(cpu) != "" {
values.Set("cpu", 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() return "/?" + values.Encode()
} }
@@ -361,6 +406,30 @@ func firstNonEmpty(values ...string) string {
return "" 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 { func formatInt64(value int64) string {
negative := value < 0 negative := value < 0
if negative { if negative {

View File

@@ -12,7 +12,7 @@
<p class="text-sm uppercase tracking-[0.35em] text-cyan-300">CPU Benchmark Platform</p> <p class="text-sm uppercase tracking-[0.35em] text-cyan-300">CPU Benchmark Platform</p>
<div class="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div class="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="max-w-3xl"> <div class="max-w-3xl">
<h1 class="text-4xl font-bold tracking-tight">Submission browser and API for local CPU benchmark runs</h1> <h1 class="text-4xl font-bold tracking-tight">Simple CPU Benchmark Server</h1>
<p class="mt-3 text-sm text-slate-300"> <p class="mt-3 text-sm text-slate-300">
Browse recent benchmark submissions, filter by submitter or CPU brand, and inspect per-core throughput details. Browse recent benchmark submissions, filter by submitter or CPU brand, and inspect per-core throughput details.
</p> </p>
@@ -31,7 +31,7 @@
<main class="mx-auto max-w-7xl px-6 py-8"> <main class="mx-auto max-w-7xl px-6 py-8">
<section class="-mt-12 rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-300/30"> <section class="-mt-12 rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-300/30">
<form method="get" action="/" class="grid gap-4 lg:grid-cols-[2fr_2fr_auto]"> <form method="get" action="/" class="grid gap-4 lg:grid-cols-[2fr_2fr_1fr_1fr_1fr_1fr_auto]">
<label class="block"> <label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">General search</span> <span class="mb-2 block text-sm font-medium text-slate-700">General search</span>
<input <input
@@ -52,6 +52,51 @@
class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500" class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
> >
</label> </label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Thread mode</span>
<select
name="thread"
class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
>
<option value="" {{ if eq .QueryThread "" }}selected{{ end }}>All</option>
<option value="single" {{ if eq .QueryThread "single" }}selected{{ end }}>Single-threaded</option>
<option value="multi" {{ if eq .QueryThread "multi" }}selected{{ end }}>Multi-threaded</option>
</select>
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Platform</span>
<select
name="platform"
class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
>
<option value="" {{ if eq .QueryPlatform "" }}selected{{ end }}>All</option>
<option value="windows" {{ if eq .QueryPlatform "windows" }}selected{{ end }}>Windows</option>
<option value="linux" {{ if eq .QueryPlatform "linux" }}selected{{ end }}>Linux</option>
<option value="macos" {{ if eq .QueryPlatform "macos" }}selected{{ end }}>macOS</option>
</select>
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Intensity</span>
<input
type="number"
min="1"
name="intensity"
value="{{ if gt .QueryIntensity 0 }}{{ .QueryIntensity }}{{ end }}"
placeholder="10"
class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
>
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Duration (s)</span>
<input
type="number"
min="1"
name="durationSecs"
value="{{ if gt .QueryDuration 0 }}{{ .QueryDuration }}{{ end }}"
placeholder="20"
class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
>
</label>
<div class="flex gap-3 lg:justify-end"> <div class="flex gap-3 lg:justify-end">
<button <button
type="submit" type="submit"
@@ -79,7 +124,7 @@
{{ range .Submissions }} {{ range .Submissions }}
<details class="group overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:shadow-md"> <details class="group overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:shadow-md">
<summary class="list-none cursor-pointer"> <summary class="list-none cursor-pointer">
<div class="grid gap-4 p-6 lg:grid-cols-[1.5fr_2fr_repeat(3,minmax(0,1fr))] lg:items-center"> <div class="grid gap-4 p-6 lg:grid-cols-[1.2fr_2fr_repeat(6,minmax(0,1fr))] lg:items-center">
<div> <div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">Submitter</p> <p class="text-xs uppercase tracking-[0.25em] text-slate-400">Submitter</p>
<p class="mt-2 text-lg font-semibold text-slate-900">{{ .Submitter }}</p> <p class="mt-2 text-lg font-semibold text-slate-900">{{ .Submitter }}</p>
@@ -102,11 +147,23 @@
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">Mode</p> <p class="text-xs uppercase tracking-[0.25em] text-slate-400">Mode</p>
<p class="mt-2 inline-flex rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-700">{{ modeLabel .Config.MultiCore }}</p> <p class="mt-2 inline-flex rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-700">{{ modeLabel .Config.MultiCore }}</p>
</div> </div>
<div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">Platform</p>
<p class="mt-2 inline-flex rounded-full bg-cyan-50 px-3 py-1 text-sm font-medium text-cyan-800">{{ .Platform }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">Intensity</p>
<p class="mt-2 text-xl font-semibold text-slate-900">{{ .Config.Intensity }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">Run Time</p>
<p class="mt-2 text-xl font-semibold text-slate-900">{{ .Config.DurationSecs }}s</p>
</div>
</div> </div>
</summary> </summary>
<div class="border-t border-slate-100 bg-slate-50 px-6 py-6"> <div class="border-t border-slate-100 bg-slate-50 px-6 py-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<div class="rounded-2xl bg-white p-4 shadow-sm"> <div class="rounded-2xl bg-white p-4 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Started</p> <p class="text-xs uppercase tracking-[0.2em] text-slate-400">Started</p>
<p class="mt-2 text-sm font-medium text-slate-800">{{ formatTime .StartedAt }}</p> <p class="mt-2 text-sm font-medium text-slate-800">{{ formatTime .StartedAt }}</p>
@@ -120,11 +177,27 @@
<p class="mt-2 text-sm font-medium text-slate-800">{{ formatInt64 .TotalOps }}</p> <p class="mt-2 text-sm font-medium text-slate-800">{{ formatInt64 .TotalOps }}</p>
</div> </div>
<div class="rounded-2xl bg-white p-4 shadow-sm"> <div class="rounded-2xl bg-white p-4 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Benchmark config</p> <p class="text-xs uppercase tracking-[0.2em] text-slate-400">Duration</p>
<p class="mt-2 text-sm font-medium text-slate-800"> <p class="mt-2 text-sm font-medium text-slate-800">
{{ .Config.DurationSecs }}s • intensity {{ .Config.Intensity }} • coreFilter {{ .Config.CoreFilter }} {{ .Config.DurationSecs }} seconds
</p> </p>
</div> </div>
<div class="rounded-2xl bg-white p-4 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Intensity</p>
<p class="mt-2 text-sm font-medium text-slate-800">
{{ .Config.Intensity }}
</p>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Core Filter</p>
<p class="mt-2 text-sm font-medium text-slate-800">
{{ .Config.CoreFilter }}
</p>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Platform</p>
<p class="mt-2 text-sm font-medium text-slate-800">{{ .Platform }}</p>
</div>
</div> </div>
<div class="mt-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white"> <div class="mt-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white">