6 Commits
v0.1 ... v0.15

Author SHA1 Message Date
03b4b55927 feat(api): support optional systemInfo in submissions
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m17s
Extend the submission contract to accept a `systemInfo` object and persist it with each submission, including deep-copy support for `extra` metadata.

Also update client-facing docs and HTTP examples (JSON and multipart) and document that the schema is available at `GET /api/schema`, so clients can reliably implement the updated payload format.feat(api): support optional systemInfo in submissions

Extend the submission contract to accept a `systemInfo` object and persist it with each submission, including deep-copy support for `extra` metadata.

Also update client-facing docs and HTTP examples (JSON and multipart) and document that the schema is available at `GET /api/schema`, so clients can reliably implement the updated payload format.
2026-04-17 13:57:55 +03:00
d2be2276ec Better logging 2026-04-17 13:25:48 +03:00
c8323d1b6a feat(search): add configurable sorting for submission results
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m17s
Introduce a `sort` query parameter in submission search paths and pass it into
store search logic. Matching results can now be ordered by `newest`, `oldest`,
`score_desc`, `score_asc`, `mops_desc`, or `mops_asc`, with invalid values
safely defaulting to `newest`.

Update README and HTTP examples to document the new sort behavior and usage so
clients can control result ordering server-side without extra post-processing.feat(search): add configurable sorting for submission results

Introduce a `sort` query parameter in submission search paths and pass it into
store search logic. Matching results can now be ordered by `newest`, `oldest`,
`score_desc`, `score_asc`, `mops_desc`, or `mops_asc`, with invalid values
safely defaulting to `newest`.

Update README and HTTP examples to document the new sort behavior and usage so
clients can control result ordering server-side without extra post-processing.
2026-04-16 00:05:21 +03:00
ec86119088 Dockerfile Fix
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m36s
2026-04-15 20:26:33 +03:00
64e3c1966d 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.
2026-04-15 20:23:37 +03:00
f21728e1ef ci(workflow): install Docker before Docker image build
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 56s
Replace the Docker availability check with an installation step in the
docker publish workflow. This ensures image builds can run on runners
where Docker is not preinstalled.ci(workflow): install Docker before Docker image build

Replace the Docker availability check with an installation step in the
docker publish workflow. This ensures image builds can run on runners
where Docker is not preinstalled.
2026-04-15 19:57:11 +03:00
14 changed files with 1341 additions and 118 deletions

View File

@@ -25,8 +25,8 @@ jobs:
- name: Run Tests
run: go test ./...
- name: Verify Docker Availability
run: docker version
- name: Install Docker
run: curl -fsSL https://get.docker.com | sh
- name: Build Docker Image
run: |

View File

@@ -16,7 +16,6 @@ RUN apk add --no-cache ca-certificates
COPY --from=builder /out/cpu-benchmark-server /app/cpu-benchmark-server
COPY templates /app/templates
COPY example_jsons /app/example_jsons
ENV APP_ADDR=:8080
ENV BADGER_DIR=/data/badger

View File

@@ -5,8 +5,9 @@ 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, duration, and sort controls.
- `GET /` renders the latest submissions with search and pagination.
- The dashboard follows the system light/dark preference by default and includes a manual theme toggle in the top-right corner.
- 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.
- Graceful shutdown closes the HTTP server and BadgerDB cleanly to avoid lock issues.
@@ -17,6 +18,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 +86,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 +110,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 +122,16 @@ Query parameters:
- `text`: token-matches submitter and general searchable fields
- `cpu`: token-matches `cpuInfo.brandString`
- `thread`: `single` or `multi`
- `platform`: `windows`, `linux`, or `macos`
- `sort`: `newest`, `oldest`, `score_desc`, `score_asc`, `mops_desc`, or `mops_asc`
- `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&sort=score_desc&intensity=10&durationSecs=30"
```
### `GET /`
@@ -127,13 +141,18 @@ Query parameters:
- `page`
- `text`
- `cpu`
- `thread`
- `platform`
- `sort`
- `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&sort=score_desc&intensity=10&durationSecs=20
```
## Request Examples
@@ -144,11 +163,19 @@ Ready-to-run HTTP client examples are included in:
- `http/submit-multipart.http`
- `http/search.http`
Client-facing submission contract docs are included in:
- `docs/submit-api.md`
- `docs/submit-schema.json`
The schema is also served by the app at `GET /api/schema`.
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 +184,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 +196,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, apply explicit platform, thread-mode, intensity, and duration filters in memory, then optionally sort the matching results by submission time, score, or MOps/sec.
## Docker

View File

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

117
docs/submit-api.md Normal file
View File

@@ -0,0 +1,117 @@
# Submission API
Use `POST /api/submit` to send benchmark results to the server.
## Endpoint
- Method: `POST`
- URL: `/api/submit`
- Content-Types:
- `application/json`
- `multipart/form-data`
## Schema
- Import URL: `/api/schema`
- Source file in repo: `docs/submit-schema.json`
## JSON format
Clients can send either:
1. An envelope with `submitter`, `platform`, optional `systemInfo`, and nested `benchmark`
2. A flat benchmark payload with optional `submitter`, `platform`, and optional `systemInfo`
Example envelope:
```json
{
"submitter": "AMD Bazzite",
"platform": "linux",
"systemInfo": {
"hostname": "bench-rig-01",
"osName": "Bazzite",
"osVersion": "41",
"distro": "Fedora Atomic",
"kernelVersion": "6.14.2-300.fc41.x86_64",
"kernelArch": "x86_64",
"architecture": "amd64",
"locale": "en-US",
"timezone": "Europe/Bucharest",
"country": "Romania",
"city": "Bucharest",
"clientVersion": "1.4.0",
"appVersion": "desktop-1.4.0",
"sessionID": "session-123",
"userID": "anonymous-user",
"extra": {
"gpuDriver": "Mesa 25.0.3",
"desktopEnvironment": "KDE Plasma"
}
},
"benchmark": {
"config": {
"durationSecs": 10,
"intensity": 1,
"coreFilter": 0,
"multiCore": true
},
"cpuInfo": {
"brandString": "AMD Ryzen 9 9950X3D 16-Core Processor",
"vendorID": "AuthenticAMD",
"physicalCores": 16,
"logicalCores": 32,
"baseClockMHz": 5756,
"boostClockMHz": 0,
"l1DataKB": 48,
"l2KB": 1024,
"l3MB": 32,
"isHybrid": false,
"has3DVCache": true,
"supportedFeatures": ["SSE4.2", "AVX", "AVX2"]
},
"startedAt": "2026-04-17T13:20:09.170770511+03:00",
"duration": 10003356676,
"totalOps": 543678267392,
"mOpsPerSec": 54349.58334499758,
"score": 5434958,
"coreResults": [
{
"logicalID": 0,
"coreType": "Standard",
"mOpsPerSec": 1642.986525056302,
"totalOps": 16435380224
}
]
}
}
```
## Multipart format
Multipart requests may include:
- `submitter`: text field
- `platform`: text field
- `systemInfo`: JSON text field
- `benchmark`, `file`, or `benchmarkFile`: benchmark JSON file field
Example `systemInfo` field value:
```json
{
"osName": "Windows",
"osVersion": "11 24H2",
"kernelVersion": "10.0.26100",
"architecture": "amd64",
"locale": "en-US",
"timezone": "America/New_York"
}
```
## Notes
- `systemInfo` is optional. Older clients do not need to send it.
- The server may enrich stored analytics with request metadata such as `ipAddress`, `forwardedFor`, `userAgent`, and `locale`.
- `baseClockMHz` and related CPU clock fields currently expect integer MHz values.
- Full JSON Schema is available in [submit-schema.json](./submit-schema.json) and is served by `GET /api/schema`.

306
docs/submit-schema.json Normal file
View File

@@ -0,0 +1,306 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cpu-benchmarker.local/schemas/submit-schema.json",
"title": "CPU Benchmark Submission",
"description": "Request body for POST /api/submit. Older clients may omit systemInfo.",
"type": "object",
"additionalProperties": false,
"properties": {
"submitter": {
"type": "string",
"description": "Human-readable device or user label."
},
"platform": {
"type": "string",
"enum": ["windows", "linux", "macos"],
"description": "Normalized platform value."
},
"systemInfo": {
"$ref": "#/$defs/systemInfo"
},
"benchmark": {
"$ref": "#/$defs/benchmarkResult"
},
"result": {
"$ref": "#/$defs/benchmarkResult"
},
"data": {
"$ref": "#/$defs/benchmarkResult"
},
"config": {
"$ref": "#/$defs/benchmarkConfig"
},
"cpuInfo": {
"$ref": "#/$defs/cpuInfo"
},
"startedAt": {
"type": "string",
"format": "date-time"
},
"duration": {
"type": "integer",
"description": "Benchmark duration in nanoseconds."
},
"totalOps": {
"type": "integer"
},
"mOpsPerSec": {
"type": "number"
},
"score": {
"type": "integer"
},
"coreResults": {
"type": "array",
"items": {
"$ref": "#/$defs/coreResult"
}
}
},
"anyOf": [
{
"required": ["benchmark"]
},
{
"required": ["result"]
},
{
"required": ["data"]
},
{
"required": ["config", "cpuInfo", "startedAt", "duration", "totalOps", "mOpsPerSec", "score", "coreResults"]
}
],
"$defs": {
"benchmarkConfig": {
"type": "object",
"additionalProperties": false,
"properties": {
"durationSecs": {
"type": "integer",
"minimum": 1
},
"intensity": {
"type": "integer"
},
"coreFilter": {
"type": "integer"
},
"multiCore": {
"type": "boolean"
}
},
"required": ["durationSecs", "intensity", "coreFilter", "multiCore"]
},
"cpuInfo": {
"type": "object",
"additionalProperties": false,
"properties": {
"brandString": {
"type": "string"
},
"vendorID": {
"type": "string"
},
"physicalCores": {
"type": "integer",
"minimum": 0
},
"logicalCores": {
"type": "integer",
"minimum": 0
},
"baseClockMHz": {
"type": "integer",
"description": "Current server schema expects an integer MHz value."
},
"boostClockMHz": {
"type": "integer"
},
"l1DataKB": {
"type": "integer"
},
"l2KB": {
"type": "integer"
},
"l3MB": {
"type": "integer"
},
"isHybrid": {
"type": "boolean"
},
"has3DVCache": {
"type": "boolean"
},
"pCoreCount": {
"type": "integer"
},
"eCoreCount": {
"type": "integer"
},
"cores": {
"type": "array",
"items": {
"$ref": "#/$defs/cpuCoreDescriptor"
}
},
"supportedFeatures": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["brandString", "physicalCores", "logicalCores"]
},
"cpuCoreDescriptor": {
"type": "object",
"additionalProperties": false,
"properties": {
"LogicalID": {
"type": "integer"
},
"PhysicalID": {
"type": "integer"
},
"CoreID": {
"type": "integer"
},
"Type": {
"type": "integer"
}
},
"required": ["LogicalID", "PhysicalID", "CoreID", "Type"]
},
"coreResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"logicalID": {
"type": "integer",
"minimum": 0
},
"coreType": {
"type": "string"
},
"mOpsPerSec": {
"type": "number",
"minimum": 0
},
"totalOps": {
"type": "integer",
"minimum": 0
}
},
"required": ["logicalID", "coreType", "mOpsPerSec", "totalOps"]
},
"benchmarkResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"config": {
"$ref": "#/$defs/benchmarkConfig"
},
"cpuInfo": {
"$ref": "#/$defs/cpuInfo"
},
"startedAt": {
"type": "string",
"format": "date-time"
},
"duration": {
"type": "integer"
},
"totalOps": {
"type": "integer"
},
"mOpsPerSec": {
"type": "number"
},
"score": {
"type": "integer"
},
"coreResults": {
"type": "array",
"items": {
"$ref": "#/$defs/coreResult"
}
}
},
"required": ["config", "cpuInfo", "startedAt", "duration", "totalOps", "mOpsPerSec", "score", "coreResults"]
},
"systemInfo": {
"type": "object",
"description": "Optional analytics and environment metadata. Clients may omit this entire object.",
"additionalProperties": false,
"properties": {
"hostname": {
"type": "string"
},
"osName": {
"type": "string"
},
"osVersion": {
"type": "string"
},
"distro": {
"type": "string"
},
"kernelVersion": {
"type": "string"
},
"kernelArch": {
"type": "string"
},
"architecture": {
"type": "string"
},
"locale": {
"type": "string"
},
"timezone": {
"type": "string"
},
"region": {
"type": "string"
},
"country": {
"type": "string"
},
"city": {
"type": "string"
},
"isp": {
"type": "string"
},
"sessionID": {
"type": "string"
},
"userID": {
"type": "string"
},
"clientVersion": {
"type": "string"
},
"appVersion": {
"type": "string"
},
"ipAddress": {
"type": "string",
"description": "May be supplied by the client, and may also be enriched by the server from the request."
},
"forwardedFor": {
"type": "string"
},
"userAgent": {
"type": "string"
},
"extra": {
"type": "object",
"description": "Free-form extension point for future analytics.",
"additionalProperties": true
}
}
}
}
}

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&sort=score_desc&intensity=10&durationSecs=30
###
GET http://localhost:8080/api/search?text=anonymous
GET http://localhost:8080/api/search?text=anonymous&thread=single&platform=linux&sort=oldest&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&sort=mops_desc&intensity=10&durationSecs=20

View File

@@ -3,6 +3,17 @@ Content-Type: application/json
{
"submitter": "Workstation-Lab-A",
"platform": "windows",
"systemInfo": {
"hostname": "workstation-lab-a",
"osName": "Windows",
"osVersion": "11 24H2",
"kernelVersion": "10.0.26100",
"architecture": "amd64",
"locale": "en-US",
"timezone": "Europe/Bucharest",
"clientVersion": "1.0.0"
},
"benchmark": {
"config": {
"durationSecs": 20,

View File

@@ -6,6 +6,14 @@ Content-Disposition: form-data; name="submitter"
Intel-Test-Rig
--BenchBoundary
Content-Disposition: form-data; name="platform"
linux
--BenchBoundary
Content-Disposition: form-data; name="systemInfo"
{"hostname":"intel-test-rig","osName":"Ubuntu","osVersion":"24.04","kernelVersion":"6.8.0-58-generic","architecture":"amd64","locale":"en-US","timezone":"Europe/Bucharest","clientVersion":"1.0.0"}
--BenchBoundary
Content-Disposition: form-data; name="benchmark"; filename="cpu-bench-result.json"
Content-Type: application/json

View File

@@ -39,6 +39,30 @@ type CPUCoreDescriptor struct {
Type int `json:"Type"`
}
type SystemInfo struct {
Hostname string `json:"hostname,omitempty"`
OSName string `json:"osName,omitempty"`
OSVersion string `json:"osVersion,omitempty"`
Distro string `json:"distro,omitempty"`
KernelVersion string `json:"kernelVersion,omitempty"`
KernelArch string `json:"kernelArch,omitempty"`
Architecture string `json:"architecture,omitempty"`
Locale string `json:"locale,omitempty"`
Timezone string `json:"timezone,omitempty"`
Region string `json:"region,omitempty"`
Country string `json:"country,omitempty"`
City string `json:"city,omitempty"`
ISP string `json:"isp,omitempty"`
SessionID string `json:"sessionID,omitempty"`
UserID string `json:"userID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
AppVersion string `json:"appVersion,omitempty"`
IPAddress string `json:"ipAddress,omitempty"`
ForwardedFor string `json:"forwardedFor,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
type CoreResult struct {
LogicalID int `json:"logicalID"`
CoreType string `json:"coreType"`
@@ -58,9 +82,11 @@ type BenchmarkResult struct {
}
type Submission struct {
SubmissionID string `json:"submissionID"`
Submitter string `json:"submitter"`
SubmittedAt time.Time `json:"submittedAt"`
SubmissionID string `json:"submissionID"`
Submitter string `json:"submitter"`
Platform string `json:"platform"`
SubmittedAt time.Time `json:"submittedAt"`
SystemInfo *SystemInfo `json:"systemInfo,omitempty"`
BenchmarkResult
}
@@ -111,6 +137,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"
@@ -136,6 +183,16 @@ func CloneSubmission(submission *Submission) *Submission {
if len(submission.CPUInfo.SupportedFeatures) > 0 {
copySubmission.CPUInfo.SupportedFeatures = append([]string(nil), submission.CPUInfo.SupportedFeatures...)
}
if submission.SystemInfo != nil {
copySystemInfo := *submission.SystemInfo
if len(submission.SystemInfo.Extra) > 0 {
copySystemInfo.Extra = make(map[string]any, len(submission.SystemInfo.Extra))
for key, value := range submission.SystemInfo.Extra {
copySystemInfo.Extra[key] = value
}
}
copySubmission.SystemInfo = &copySystemInfo
}
return &copySubmission
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
@@ -21,6 +22,10 @@ type indexedSubmission struct {
submission *model.Submission
searchText string
cpuText string
platform string
threadMode string
intensity int
duration int
}
type Store struct {
@@ -61,11 +66,13 @@ 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, systemInfo *model.SystemInfo) (*model.Submission, error) {
submission := &model.Submission{
SubmissionID: uuid.NewString(),
Submitter: model.NormalizeSubmitter(submitter),
Platform: model.NormalizePlatform(platform),
SubmittedAt: time.Now().UTC(),
SystemInfo: cloneSystemInfo(systemInfo),
BenchmarkResult: result,
}
@@ -91,6 +98,22 @@ func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter string) (
return model.CloneSubmission(submission), nil
}
func cloneSystemInfo(info *model.SystemInfo) *model.SystemInfo {
if info == nil {
return nil
}
copyInfo := *info
if len(info.Extra) > 0 {
copyInfo.Extra = make(map[string]any, len(info.Extra))
for key, value := range info.Extra {
copyInfo.Extra[key] = value
}
}
return &copyInfo
}
func (s *Store) ListSubmissions(page, pageSize int) ([]model.Submission, int) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -111,9 +134,12 @@ 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, sortBy string, intensity, durationSecs int) []model.Submission {
queryText := normalizeSearchText(text)
cpuText := normalizeSearchText(cpu)
thread = normalizeThreadFilter(thread)
platform = normalizePlatformFilter(platform)
sortBy = normalizeSortOption(sortBy)
s.mu.RLock()
defer s.mu.RUnlock()
@@ -133,9 +159,26 @@ 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))
}
sortSubmissions(results, sortBy)
return results
}
@@ -173,6 +216,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 +227,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 +280,106 @@ 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 normalizeSortOption(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "newest":
return "newest"
case "oldest":
return "oldest"
case "score_desc":
return "score_desc"
case "score_asc":
return "score_asc"
case "mops_desc":
return "mops_desc"
case "mops_asc":
return "mops_asc"
default:
return "newest"
}
}
func sortSubmissions(submissions []model.Submission, sortBy string) {
switch sortBy {
case "oldest":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].SubmittedAt.Equal(submissions[j].SubmittedAt) {
return submissions[i].SubmissionID < submissions[j].SubmissionID
}
return submissions[i].SubmittedAt.Before(submissions[j].SubmittedAt)
})
case "score_desc":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].Score == submissions[j].Score {
return submissions[i].SubmittedAt.After(submissions[j].SubmittedAt)
}
return submissions[i].Score > submissions[j].Score
})
case "score_asc":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].Score == submissions[j].Score {
return submissions[i].SubmittedAt.After(submissions[j].SubmittedAt)
}
return submissions[i].Score < submissions[j].Score
})
case "mops_desc":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].MOpsPerSec == submissions[j].MOpsPerSec {
return submissions[i].SubmittedAt.After(submissions[j].SubmittedAt)
}
return submissions[i].MOpsPerSec > submissions[j].MOpsPerSec
})
case "mops_asc":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].MOpsPerSec == submissions[j].MOpsPerSec {
return submissions[i].SubmittedAt.After(submissions[j].SubmittedAt)
}
return submissions[i].MOpsPerSec < submissions[j].MOpsPerSec
})
}
}
func pageBounds(page, pageSize, total int) (int, int, int) {
if pageSize <= 0 {
pageSize = 50

View File

@@ -5,11 +5,16 @@ import (
"fmt"
"html/template"
"io"
"log"
"mime"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"cpu-benchmark-server/lib/model"
@@ -25,30 +30,39 @@ type App struct {
store *store.Store
templates *template.Template
pageSize int
errorLog *dailyErrorLogger
}
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
QuerySort 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"`
Benchmark *model.BenchmarkResult `json:"benchmark"`
Result *model.BenchmarkResult `json:"result"`
Data *model.BenchmarkResult `json:"data"`
Submitter string `json:"submitter"`
Platform string `json:"platform"`
Benchmark json.RawMessage `json:"benchmark"`
Result json.RawMessage `json:"result"`
Data json.RawMessage `json:"data"`
}
type flatSubmissionEnvelope struct {
Submitter string `json:"submitter"`
Submitter string `json:"submitter"`
Platform string `json:"platform"`
SystemInfo *model.SystemInfo `json:"systemInfo,omitempty"`
model.BenchmarkResult
}
@@ -56,7 +70,7 @@ type errorResponse struct {
Error string `json:"error"`
}
func New(store *store.Store, pageSize int) (*App, error) {
func New(store *store.Store, pageSize int, errorLogDir string) (*App, error) {
funcs := template.FuncMap{
"formatInt64": formatInt64,
"formatFloat": formatFloat,
@@ -69,10 +83,16 @@ func New(store *store.Store, pageSize int) (*App, error) {
return nil, err
}
errorLog, err := newDailyErrorLogger(errorLogDir)
if err != nil {
return nil, err
}
return &App{
store: store,
templates: templates,
pageSize: pageSize,
errorLog: errorLog,
}, nil
}
@@ -80,30 +100,193 @@ func (a *App) Routes() http.Handler {
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(a.errorLoggingMiddleware)
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
router.Use(middleware.Timeout(30 * time.Second))
router.Get("/", a.handleIndex)
router.Get("/healthz", a.handleHealth)
router.Get("/api/schema", a.handleSubmitSchema)
router.Get("/api/search", a.handleSearch)
router.Post("/api/submit", a.handleSubmit)
return router
}
func (a *App) Close() error {
if a == nil || a.errorLog == nil {
return nil
}
return a.errorLog.Close()
}
type dailyErrorLogger struct {
dir string
mu sync.Mutex
date string
file *os.File
logger *log.Logger
}
const maxLoggedResponseBodyBytes = 2048
func newDailyErrorLogger(dir string) (*dailyErrorLogger, error) {
l := &dailyErrorLogger{dir: dir}
if err := l.rotateIfNeeded(time.Now()); err != nil {
return nil, err
}
return l, nil
}
func (l *dailyErrorLogger) rotateIfNeeded(now time.Time) error {
date := now.Format("2006-01-02")
if l.file != nil && l.date == date {
return nil
}
if err := os.MkdirAll(l.dir, 0o755); err != nil {
return err
}
path := filepath.Join(l.dir, date+".error.log")
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
if l.file != nil {
_ = l.file.Close()
}
l.date = date
l.file = file
l.logger = log.New(file, "", log.LstdFlags)
return nil
}
func (l *dailyErrorLogger) LogRequest(r *http.Request, status int, duration time.Duration, responseBody string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
if err := l.rotateIfNeeded(now); err != nil {
log.Printf("error log rotation failed: %v", err)
return
}
l.logger.Printf(
`status=%d method=%s path=%q query=%q ip=%q request_id=%q duration=%s content_type=%q content_length=%d user_agent=%q response_body=%q`,
status,
r.Method,
r.URL.Path,
r.URL.RawQuery,
r.RemoteAddr,
middleware.GetReqID(r.Context()),
duration,
r.Header.Get("Content-Type"),
r.ContentLength,
r.UserAgent(),
responseBody,
)
}
func (l *dailyErrorLogger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.file == nil {
return nil
}
err := l.file.Close()
l.file = nil
l.logger = nil
l.date = ""
return err
}
type statusCapturingResponseWriter struct {
http.ResponseWriter
status int
body strings.Builder
bodyTruncated bool
}
func (w *statusCapturingResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *statusCapturingResponseWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
if w.body.Len() < maxLoggedResponseBodyBytes {
remaining := maxLoggedResponseBodyBytes - w.body.Len()
if len(b) > remaining {
_, _ = w.body.Write(b[:remaining])
w.bodyTruncated = true
} else {
_, _ = w.body.Write(b)
}
} else {
w.bodyTruncated = true
}
return w.ResponseWriter.Write(b)
}
func (w *statusCapturingResponseWriter) LoggedBody() string {
body := strings.TrimSpace(w.body.String())
if body == "" {
return ""
}
if w.bodyTruncated {
return body + "... [truncated]"
}
return body
}
func (a *App) errorLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
ww := &statusCapturingResponseWriter{ResponseWriter: w}
next.ServeHTTP(ww, r)
status := ww.status
if status == 0 {
status = http.StatusOK
}
if status >= http.StatusBadRequest {
a.errorLog.LogRequest(r, status, time.Since(startedAt), ww.LoggedBody())
}
})
}
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"))
sortBy := normalizeSortFilter(r.URL.Query().Get("sort"))
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 || sortBy != "newest" {
matches := a.store.SearchSubmissions(text, cpu, thread, platform, sortBy, intensity, durationSecs)
totalCount = len(matches)
start, end, normalizedPage := pageBounds(page, a.pageSize, totalCount)
page = normalizedPage
@@ -119,16 +302,21 @@ 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),
QuerySort: sortBy,
QueryIntensity: intensity,
QueryDuration: durationSecs,
Page: page,
TotalPages: totalPageCount,
TotalCount: totalCount,
ShowingFrom: showingFrom,
ShowingTo: showingTo,
PrevURL: buildIndexURL(max(1, page-1), text, cpu, thread, platform, sortBy, intensity, durationSecs),
NextURL: buildIndexURL(max(1, min(totalPageCount, page+1)), text, cpu, thread, platform, sortBy, intensity, durationSecs),
}
if err := a.templates.ExecuteTemplate(w, "index.html", data); err != nil {
@@ -143,15 +331,27 @@ func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) {
})
}
func (a *App) handleSubmitSchema(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, os.DirFS("."), "docs/submit-schema.json")
}
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"),
r.URL.Query().Get("sort"),
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, systemInfo, err := parseSubmissionRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
return
@@ -162,7 +362,15 @@ 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
}
systemInfo = enrichSystemInfo(systemInfo, r)
submission, err := a.store.SaveSubmission(result, submitter, platform, systemInfo)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: fmt.Sprintf("store submission: %v", err)})
return
@@ -172,15 +380,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, *model.SystemInfo, 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{}, "", "", nil, fmt.Errorf("parse content type: %w", err)
}
switch mediaType {
@@ -189,55 +398,83 @@ 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{}, "", "", nil, 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, *model.SystemInfo, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return model.BenchmarkResult{}, "", fmt.Errorf("read request body: %w", err)
return model.BenchmarkResult{}, "", "", nil, 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)
for _, candidate := range []*model.BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} {
if candidate != nil {
return *candidate, submitter, nil
platform = firstNonEmpty(nested.Platform, platform)
systemInfo, err := extractSystemInfo(body)
if err != nil {
return model.BenchmarkResult{}, "", "", nil, err
}
for _, candidate := range []struct {
name string
payload json.RawMessage
}{
{name: "benchmark", payload: nested.Benchmark},
{name: "result", payload: nested.Result},
{name: "data", payload: nested.Data},
} {
if len(candidate.payload) == 0 || string(candidate.payload) == "null" {
continue
}
var result model.BenchmarkResult
if err := json.Unmarshal(candidate.payload, &result); err != nil {
return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("decode %s JSON: %w", candidate.name, err)
}
return result, submitter, platform, systemInfo, 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{}, "", "", nil, 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"), cloneSystemInfo(flat.SystemInfo), nil
}
func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, error) {
func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, string, *model.SystemInfo, error) {
if err := r.ParseMultipartForm(maxSubmissionBytes); err != nil {
return model.BenchmarkResult{}, "", fmt.Errorf("parse multipart form: %w", err)
return model.BenchmarkResult{}, "", "", nil, fmt.Errorf("parse multipart form: %w", err)
}
payload, err := readMultipartPayload(r)
if err != nil {
return model.BenchmarkResult{}, "", err
return model.BenchmarkResult{}, "", "", nil, 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{}, "", "", nil, fmt.Errorf("decode benchmark JSON: %w", err)
}
return result, r.FormValue("submitter"), nil
systemInfo, err := parseMultipartSystemInfo(r)
if err != nil {
return model.BenchmarkResult{}, "", "", nil, err
}
return result, r.FormValue("submitter"), firstNonEmpty(r.FormValue("platform"), "windows"), systemInfo, nil
}
func readMultipartPayload(r *http.Request) ([]byte, error) {
@@ -267,6 +504,99 @@ func readMultipartPayload(r *http.Request) ([]byte, error) {
return nil, fmt.Errorf("multipart request must include benchmark JSON in a file field or text field named benchmark")
}
func extractSystemInfo(body []byte) (*model.SystemInfo, error) {
var envelope struct {
SystemInfo *model.SystemInfo `json:"systemInfo"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("decode systemInfo JSON: %w", err)
}
return cloneSystemInfo(envelope.SystemInfo), nil
}
func parseMultipartSystemInfo(r *http.Request) (*model.SystemInfo, error) {
raw := strings.TrimSpace(r.FormValue("systemInfo"))
if raw == "" {
return nil, nil
}
var info model.SystemInfo
if err := json.Unmarshal([]byte(raw), &info); err != nil {
return nil, fmt.Errorf("decode systemInfo JSON: %w", err)
}
return &info, nil
}
func enrichSystemInfo(info *model.SystemInfo, r *http.Request) *model.SystemInfo {
if info == nil {
info = &model.SystemInfo{}
} else {
info = cloneSystemInfo(info)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
info.IPAddress = firstNonEmpty(info.IPAddress, host)
} else {
info.IPAddress = firstNonEmpty(info.IPAddress, r.RemoteAddr)
}
info.ForwardedFor = firstNonEmpty(info.ForwardedFor, r.Header.Get("X-Forwarded-For"))
info.UserAgent = firstNonEmpty(info.UserAgent, r.UserAgent())
info.Locale = firstNonEmpty(info.Locale, r.Header.Get("Accept-Language"))
if isEmptySystemInfo(info) {
return nil
}
return info
}
func isEmptySystemInfo(info *model.SystemInfo) bool {
if info == nil {
return true
}
return strings.TrimSpace(info.Hostname) == "" &&
strings.TrimSpace(info.OSName) == "" &&
strings.TrimSpace(info.OSVersion) == "" &&
strings.TrimSpace(info.Distro) == "" &&
strings.TrimSpace(info.KernelVersion) == "" &&
strings.TrimSpace(info.KernelArch) == "" &&
strings.TrimSpace(info.Architecture) == "" &&
strings.TrimSpace(info.Locale) == "" &&
strings.TrimSpace(info.Timezone) == "" &&
strings.TrimSpace(info.Region) == "" &&
strings.TrimSpace(info.Country) == "" &&
strings.TrimSpace(info.City) == "" &&
strings.TrimSpace(info.ISP) == "" &&
strings.TrimSpace(info.SessionID) == "" &&
strings.TrimSpace(info.UserID) == "" &&
strings.TrimSpace(info.ClientVersion) == "" &&
strings.TrimSpace(info.AppVersion) == "" &&
strings.TrimSpace(info.IPAddress) == "" &&
strings.TrimSpace(info.ForwardedFor) == "" &&
strings.TrimSpace(info.UserAgent) == "" &&
len(info.Extra) == 0
}
func cloneSystemInfo(info *model.SystemInfo) *model.SystemInfo {
if info == nil {
return nil
}
copyInfo := *info
if len(info.Extra) > 0 {
copyInfo.Extra = make(map[string]any, len(info.Extra))
for key, value := range info.Extra {
copyInfo.Extra[key] = value
}
}
return &copyInfo
}
func parsePositiveInt(raw string, fallback int) int {
if raw == "" {
return fallback
@@ -326,7 +656,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, sortBy string, intensity, durationSecs int) string {
if page < 1 {
page = 1
}
@@ -339,6 +669,21 @@ 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 normalizedSort := normalizeSortFilter(sortBy); normalizedSort != "newest" {
values.Set("sort", normalizedSort)
}
if intensity > 0 {
values.Set("intensity", strconv.Itoa(intensity))
}
if durationSecs > 0 {
values.Set("durationSecs", strconv.Itoa(durationSecs))
}
return "/?" + values.Encode()
}
@@ -361,6 +706,49 @@ 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 normalizeSortFilter(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "newest":
return "newest"
case "oldest":
return "oldest"
case "score_desc":
return "score_desc"
case "score_asc":
return "score_asc"
case "mops_desc":
return "mops_desc"
case "mops_asc":
return "mops_asc"
default:
return "newest"
}
}
func formatInt64(value int64) string {
negative := value < 0
if negative {

10
main.go
View File

@@ -29,6 +29,9 @@ func run(logger *log.Logger) error {
if err := os.MkdirAll(cfg.BadgerDir, 0o755); err != nil {
return err
}
if err := os.MkdirAll("data/logs", 0o755); err != nil {
return err
}
benchmarkStore, err := store.Open(cfg.BadgerDir)
if err != nil {
@@ -43,10 +46,15 @@ func run(logger *log.Logger) error {
}
defer closeOnce.Do(closeStore)
app, err := web.New(benchmarkStore, cfg.PageSize)
app, err := web.New(benchmarkStore, cfg.PageSize, "data/logs")
if err != nil {
return err
}
defer func() {
if err := app.Close(); err != nil {
logger.Printf("close error log: %v", err)
}
}()
server := &http.Server{
Addr: cfg.Addr,

View File

@@ -5,19 +5,52 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CPU Benchmark Submissions</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
<script>
tailwind.config = {
darkMode: "class"
};
</script>
<script>
(function() {
var storageKey = "cpu-benchmark-theme";
var root = document.documentElement;
var stored = null;
try {
stored = localStorage.getItem(storageKey);
} catch (err) {}
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
var theme = stored === "light" || stored === "dark" ? stored : (prefersDark ? "dark" : "light");
root.classList.toggle("dark", theme === "dark");
root.dataset.theme = theme;
})();
</script>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
<div class="bg-slate-950 text-white">
<body class="min-h-screen bg-slate-100 text-slate-900 transition-colors dark:bg-slate-950 dark:text-slate-100">
<div class="bg-slate-950 text-white dark:bg-slate-900">
<div class="mx-auto max-w-7xl px-6 py-10">
<p class="text-sm uppercase tracking-[0.35em] text-cyan-300">CPU Benchmark Platform</p>
<div class="flex items-start justify-between gap-4">
<p class="text-sm uppercase tracking-[0.35em] text-cyan-300">CPU Benchmark Platform</p>
<button
type="button"
id="theme-toggle"
aria-label="Toggle theme"
class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-2 text-sm font-medium text-slate-100 backdrop-blur transition hover:bg-white/15"
>
<span id="theme-toggle-icon" aria-hidden="true"></span>
<span id="theme-toggle-label">Theme</span>
</button>
</div>
<div class="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<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">
Browse recent benchmark submissions, filter by submitter or CPU brand, and inspect per-core throughput details.
</p>
</div>
<div class="rounded-2xl bg-white/10 px-4 py-3 text-sm text-slate-200 backdrop-blur">
<div class="rounded-2xl bg-white/10 px-4 py-3 text-sm text-slate-200 backdrop-blur dark:bg-white/5">
<div>Total results: <span class="font-semibold text-white">{{ .TotalCount }}</span></div>
{{ if gt .ShowingTo 0 }}
<div>Showing {{ .ShowingFrom }} to {{ .ShowingTo }}</div>
@@ -30,28 +63,87 @@
</div>
<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">
<form method="get" action="/" class="grid gap-4 lg:grid-cols-[2fr_2fr_auto]">
<section class="-mt-12 rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-300/30 transition-colors dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20">
<form method="get" action="/" class="grid gap-4 lg:grid-cols-[2fr_2fr_1fr_1fr_1fr_1fr_1fr_auto]">
<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 dark:text-slate-300">General search</span>
<input
type="text"
name="text"
value="{{ .QueryText }}"
placeholder="Submitter, score, vendor, features, thread mode"
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 text-slate-900 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
>
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">CPU brand</span>
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">CPU brand</span>
<input
type="text"
name="cpu"
value="{{ .QueryCPU }}"
placeholder="Ryzen 7 5800X, i7-13700K, Xeon"
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 text-slate-900 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
>
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">Thread mode</span>
<select
name="thread"
class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm text-slate-900 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
>
<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 dark:text-slate-300">Platform</span>
<select
name="platform"
class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm text-slate-900 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
>
<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 dark:text-slate-300">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 text-slate-900 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
>
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">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 text-slate-900 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
>
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">Sort</span>
<select
name="sort"
class="w-full rounded-xl border-slate-300 bg-slate-50 text-sm text-slate-900 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
>
<option value="newest" {{ if eq .QuerySort "newest" }}selected{{ end }}>Newest first</option>
<option value="oldest" {{ if eq .QuerySort "oldest" }}selected{{ end }}>Oldest first</option>
<option value="score_desc" {{ if eq .QuerySort "score_desc" }}selected{{ end }}>Highest score</option>
<option value="score_asc" {{ if eq .QuerySort "score_asc" }}selected{{ end }}>Lowest score</option>
<option value="mops_desc" {{ if eq .QuerySort "mops_desc" }}selected{{ end }}>Highest MOps/sec</option>
<option value="mops_asc" {{ if eq .QuerySort "mops_asc" }}selected{{ end }}>Lowest MOps/sec</option>
</select>
</label>
<div class="flex gap-3 lg:justify-end">
<button
type="submit"
@@ -61,7 +153,7 @@
</button>
<a
href="/"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
Clear
</a>
@@ -71,24 +163,24 @@
<section class="mt-8 space-y-4">
{{ if not .Submissions }}
<div class="rounded-2xl border border-dashed border-slate-300 bg-white px-6 py-12 text-center text-slate-500">
<div class="rounded-2xl border border-dashed border-slate-300 bg-white px-6 py-12 text-center text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
No submissions to display.
</div>
{{ end }}
{{ 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 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20">
<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>
<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-1 text-xs text-slate-500">{{ .SubmissionID }}</p>
<p class="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ .Submitter }}</p>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ .SubmissionID }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">CPU</p>
<p class="mt-2 font-semibold text-slate-900">{{ .CPUInfo.BrandString }}</p>
<p class="mt-1 text-sm text-slate-500">{{ .CPUInfo.VendorID }} • {{ .CPUInfo.PhysicalCores }}C / {{ .CPUInfo.LogicalCores }}T</p>
<p class="mt-2 font-semibold text-slate-900 dark:text-slate-100">{{ .CPUInfo.BrandString }}</p>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ .CPUInfo.VendorID }} • {{ .CPUInfo.PhysicalCores }}C / {{ .CPUInfo.LogicalCores }}T</p>
</div>
<div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">Score</p>
@@ -96,40 +188,68 @@
</div>
<div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">MOps/sec</p>
<p class="mt-2 text-xl font-semibold text-slate-900">{{ formatFloat .MOpsPerSec }}</p>
<p class="mt-2 text-xl font-semibold text-slate-900 dark:text-slate-100">{{ formatFloat .MOpsPerSec }}</p>
</div>
<div>
<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 dark:bg-slate-800 dark:text-slate-200">{{ modeLabel .Config.MultiCore }}</p>
</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 dark:text-slate-100">{{ .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 dark:text-slate-100">{{ .Config.DurationSecs }}s</p>
</div>
</div>
</summary>
<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="rounded-2xl bg-white p-4 shadow-sm">
<div class="border-t border-slate-100 bg-slate-50 px-6 py-6 dark:border-slate-800 dark:bg-slate-950/70">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-slate-900">
<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 dark:text-slate-200">{{ formatTime .StartedAt }}</p>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm">
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-slate-900">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Submitted</p>
<p class="mt-2 text-sm font-medium text-slate-800">{{ formatTime .SubmittedAt }}</p>
<p class="mt-2 text-sm font-medium text-slate-800 dark:text-slate-200">{{ formatTime .SubmittedAt }}</p>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm">
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-slate-900">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Total ops</p>
<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 dark:text-slate-200">{{ formatInt64 .TotalOps }}</p>
</div>
<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="mt-2 text-sm font-medium text-slate-800">
{{ .Config.DurationSecs }}s • intensity {{ .Config.Intensity }} • coreFilter {{ .Config.CoreFilter }}
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-slate-900">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Duration</p>
<p class="mt-2 text-sm font-medium text-slate-800 dark:text-slate-200">
{{ .Config.DurationSecs }} seconds
</p>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-slate-900">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Intensity</p>
<p class="mt-2 text-sm font-medium text-slate-800 dark:text-slate-200">
{{ .Config.Intensity }}
</p>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-slate-900">
<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 dark:text-slate-200">
{{ .Config.CoreFilter }}
</p>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-slate-900">
<p class="text-xs uppercase tracking-[0.2em] text-slate-400">Platform</p>
<p class="mt-2 text-sm font-medium text-slate-800 dark:text-slate-200">{{ .Platform }}</p>
</div>
</div>
<div class="mt-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-100 text-slate-600">
<div class="mt-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<table class="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead class="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<tr>
<th class="px-4 py-3 text-left font-semibold">Logical ID</th>
<th class="px-4 py-3 text-left font-semibold">Core Type</th>
@@ -137,17 +257,17 @@
<th class="px-4 py-3 text-left font-semibold">Total Ops</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
{{ range .CoreResults }}
<tr>
<td class="px-4 py-3 font-medium text-slate-900">{{ .LogicalID }}</td>
<td class="px-4 py-3 text-slate-700">{{ .CoreType }}</td>
<td class="px-4 py-3 text-slate-700">{{ formatFloat .MOpsPerSec }}</td>
<td class="px-4 py-3 text-slate-700">{{ formatInt64 .TotalOps }}</td>
<td class="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{{ .LogicalID }}</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-300">{{ .CoreType }}</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-300">{{ formatFloat .MOpsPerSec }}</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-300">{{ formatInt64 .TotalOps }}</td>
</tr>
{{ else }}
<tr>
<td colspan="4" class="px-4 py-6 text-center text-slate-500">No per-core results available.</td>
<tr class="dark:bg-slate-900">
<td colspan="4" class="px-4 py-6 text-center text-slate-500 dark:text-slate-400">No per-core results available.</td>
</tr>
{{ end }}
</tbody>
@@ -159,24 +279,59 @@
</section>
{{ if gt .TotalPages 1 }}
<nav class="mt-8 flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm md:flex-row md:items-center md:justify-between">
<div class="text-sm text-slate-500">
<nav class="mt-8 flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm md:flex-row md:items-center md:justify-between dark:bg-slate-900 dark:shadow-black/20">
<div class="text-sm text-slate-500 dark:text-slate-400">
Page {{ .Page }} of {{ .TotalPages }}
</div>
<div class="flex gap-3">
{{ if gt .Page 1 }}
<a href="{{ .PrevURL }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50">Previous</a>
<a href="{{ .PrevURL }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800">Previous</a>
{{ else }}
<span class="cursor-not-allowed rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-400">Previous</span>
<span class="cursor-not-allowed rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-400 dark:border-slate-800 dark:text-slate-600">Previous</span>
{{ end }}
{{ if lt .Page .TotalPages }}
<a href="{{ .NextURL }}" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700">Next</a>
<a href="{{ .NextURL }}" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-cyan-600 dark:hover:bg-cyan-500">Next</a>
{{ else }}
<span class="cursor-not-allowed rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-400">Next</span>
<span class="cursor-not-allowed rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-400 dark:border-slate-800 dark:text-slate-600">Next</span>
{{ end }}
</div>
</nav>
{{ end }}
</main>
<script>
(function() {
var storageKey = "cpu-benchmark-theme";
var root = document.documentElement;
var button = document.getElementById("theme-toggle");
var icon = document.getElementById("theme-toggle-icon");
var label = document.getElementById("theme-toggle-label");
if (!button || !icon || !label) {
return;
}
function applyTheme(theme, persist) {
currentTheme = theme;
root.classList.toggle("dark", theme === "dark");
root.dataset.theme = theme;
button.setAttribute("aria-pressed", String(theme === "dark"));
icon.textContent = theme === "dark" ? "☾" : "☼";
label.textContent = theme === "dark" ? "Dark" : "Light";
if (persist) {
try {
localStorage.setItem(storageKey, theme);
} catch (err) {}
}
}
var currentTheme = root.dataset.theme === "dark" ? "dark" : "light";
applyTheme(currentTheme, false);
button.addEventListener("click", function() {
applyTheme(currentTheme === "dark" ? "light" : "dark", true);
});
})();
</script>
</body>
</html>