5 Commits

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
13 changed files with 1339 additions and 116 deletions

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 --from=builder /out/cpu-benchmark-server /app/cpu-benchmark-server
COPY templates /app/templates COPY templates /app/templates
COPY example_jsons /app/example_jsons
ENV APP_ADDR=:8080 ENV APP_ADDR=:8080
ENV BADGER_DIR=/data/badger ENV BADGER_DIR=/data/badger

View File

@@ -5,8 +5,9 @@ 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, duration, and sort controls.
- `GET /` renders the latest submissions with search and pagination. - `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. - 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.
- Graceful shutdown closes the HTTP server and BadgerDB cleanly to avoid lock issues. - 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 - `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 +86,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 +110,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 +122,16 @@ 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`
- `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: 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&sort=score_desc&intensity=10&durationSecs=30"
``` ```
### `GET /` ### `GET /`
@@ -127,13 +141,18 @@ Query parameters:
- `page` - `page`
- `text` - `text`
- `cpu` - `cpu`
- `thread`
- `platform`
- `sort`
- `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&sort=score_desc&intensity=10&durationSecs=20
``` ```
## Request Examples ## Request Examples
@@ -144,11 +163,19 @@ Ready-to-run HTTP client examples are included in:
- `http/submit-multipart.http` - `http/submit-multipart.http`
- `http/search.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: 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 +184,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 +196,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, 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 ## 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:

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", "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": { "benchmark": {
"config": { "config": {
"durationSecs": 20, "durationSecs": 20,

View File

@@ -6,6 +6,14 @@ 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="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-Disposition: form-data; name="benchmark"; filename="cpu-bench-result.json"
Content-Type: application/json Content-Type: application/json

View File

@@ -39,6 +39,30 @@ type CPUCoreDescriptor struct {
Type int `json:"Type"` 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 { type CoreResult struct {
LogicalID int `json:"logicalID"` LogicalID int `json:"logicalID"`
CoreType string `json:"coreType"` CoreType string `json:"coreType"`
@@ -58,9 +82,11 @@ 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"`
SubmittedAt time.Time `json:"submittedAt"` Platform string `json:"platform"`
SubmittedAt time.Time `json:"submittedAt"`
SystemInfo *SystemInfo `json:"systemInfo,omitempty"`
BenchmarkResult BenchmarkResult
} }
@@ -111,6 +137,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"
@@ -136,6 +183,16 @@ func CloneSubmission(submission *Submission) *Submission {
if len(submission.CPUInfo.SupportedFeatures) > 0 { if len(submission.CPUInfo.SupportedFeatures) > 0 {
copySubmission.CPUInfo.SupportedFeatures = append([]string(nil), submission.CPUInfo.SupportedFeatures...) 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 return &copySubmission
} }

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -21,6 +22,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,11 +66,13 @@ 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, systemInfo *model.SystemInfo) (*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(),
SystemInfo: cloneSystemInfo(systemInfo),
BenchmarkResult: result, BenchmarkResult: result,
} }
@@ -91,6 +98,22 @@ func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter string) (
return model.CloneSubmission(submission), nil 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) { func (s *Store) ListSubmissions(page, pageSize int) ([]model.Submission, int) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -111,9 +134,12 @@ 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, sortBy string, intensity, durationSecs int) []model.Submission {
queryText := normalizeSearchText(text) queryText := normalizeSearchText(text)
cpuText := normalizeSearchText(cpu) cpuText := normalizeSearchText(cpu)
thread = normalizeThreadFilter(thread)
platform = normalizePlatformFilter(platform)
sortBy = normalizeSortOption(sortBy)
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -133,9 +159,26 @@ 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))
} }
sortSubmissions(results, sortBy)
return results return results
} }
@@ -173,6 +216,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 +227,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 +280,106 @@ 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 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) { func pageBounds(page, pageSize, total int) (int, int, int) {
if pageSize <= 0 { if pageSize <= 0 {
pageSize = 50 pageSize = 50

View File

@@ -5,11 +5,16 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"log"
"mime" "mime"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"cpu-benchmark-server/lib/model" "cpu-benchmark-server/lib/model"
@@ -25,30 +30,39 @@ type App struct {
store *store.Store store *store.Store
templates *template.Template templates *template.Template
pageSize int pageSize int
errorLog *dailyErrorLogger
} }
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 QuerySort string
ShowingFrom int QueryIntensity int
ShowingTo int QueryDuration int
PrevURL string Page int
NextURL string TotalPages int
TotalCount int
ShowingFrom int
ShowingTo int
PrevURL string
NextURL string
} }
type jsonSubmissionEnvelope struct { type jsonSubmissionEnvelope struct {
Submitter string `json:"submitter"` Submitter string `json:"submitter"`
Benchmark *model.BenchmarkResult `json:"benchmark"` Platform string `json:"platform"`
Result *model.BenchmarkResult `json:"result"` Benchmark json.RawMessage `json:"benchmark"`
Data *model.BenchmarkResult `json:"data"` Result json.RawMessage `json:"result"`
Data json.RawMessage `json:"data"`
} }
type flatSubmissionEnvelope struct { type flatSubmissionEnvelope struct {
Submitter string `json:"submitter"` Submitter string `json:"submitter"`
Platform string `json:"platform"`
SystemInfo *model.SystemInfo `json:"systemInfo,omitempty"`
model.BenchmarkResult model.BenchmarkResult
} }
@@ -56,7 +70,7 @@ type errorResponse struct {
Error string `json:"error"` 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{ funcs := template.FuncMap{
"formatInt64": formatInt64, "formatInt64": formatInt64,
"formatFloat": formatFloat, "formatFloat": formatFloat,
@@ -69,10 +83,16 @@ func New(store *store.Store, pageSize int) (*App, error) {
return nil, err return nil, err
} }
errorLog, err := newDailyErrorLogger(errorLogDir)
if err != nil {
return nil, err
}
return &App{ return &App{
store: store, store: store,
templates: templates, templates: templates,
pageSize: pageSize, pageSize: pageSize,
errorLog: errorLog,
}, nil }, nil
} }
@@ -80,30 +100,193 @@ func (a *App) Routes() http.Handler {
router := chi.NewRouter() router := chi.NewRouter()
router.Use(middleware.RequestID) router.Use(middleware.RequestID)
router.Use(middleware.RealIP) router.Use(middleware.RealIP)
router.Use(a.errorLoggingMiddleware)
router.Use(middleware.Logger) router.Use(middleware.Logger)
router.Use(middleware.Recoverer) router.Use(middleware.Recoverer)
router.Use(middleware.Timeout(30 * time.Second)) router.Use(middleware.Timeout(30 * time.Second))
router.Get("/", a.handleIndex) router.Get("/", a.handleIndex)
router.Get("/healthz", a.handleHealth) router.Get("/healthz", a.handleHealth)
router.Get("/api/schema", a.handleSubmitSchema)
router.Get("/api/search", a.handleSearch) router.Get("/api/search", a.handleSearch)
router.Post("/api/submit", a.handleSubmit) router.Post("/api/submit", a.handleSubmit)
return router 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) { 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"))
sortBy := normalizeSortFilter(r.URL.Query().Get("sort"))
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 || sortBy != "newest" {
matches := a.store.SearchSubmissions(text, cpu) matches := a.store.SearchSubmissions(text, cpu, thread, platform, sortBy, 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 +302,21 @@ 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, QuerySort: sortBy,
ShowingFrom: showingFrom, QueryIntensity: intensity,
ShowingTo: showingTo, QueryDuration: durationSecs,
PrevURL: buildIndexURL(max(1, page-1), text, cpu), Page: page,
NextURL: buildIndexURL(max(1, min(totalPageCount, page+1)), text, cpu), 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 { 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) { 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) 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, systemInfo, 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 +362,15 @@ 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
}
systemInfo = enrichSystemInfo(systemInfo, r)
submission, err := a.store.SaveSubmission(result, submitter, platform, systemInfo)
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 +380,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, *model.SystemInfo, 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{}, "", "", nil, fmt.Errorf("parse content type: %w", err)
} }
switch mediaType { switch mediaType {
@@ -189,55 +398,83 @@ 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{}, "", "", 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) 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{}, "", "", nil, 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)
for _, candidate := range []*model.BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} { platform = firstNonEmpty(nested.Platform, platform)
if candidate != nil { systemInfo, err := extractSystemInfo(body)
return *candidate, submitter, nil 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 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{}, "", "", 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 { 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) payload, err := readMultipartPayload(r)
if err != nil { if err != nil {
return model.BenchmarkResult{}, "", err return model.BenchmarkResult{}, "", "", nil, 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{}, "", "", 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) { 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") 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 { func parsePositiveInt(raw string, fallback int) int {
if raw == "" { if raw == "" {
return fallback return fallback
@@ -326,7 +656,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, sortBy string, intensity, durationSecs int) string {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -339,6 +669,21 @@ 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 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() return "/?" + values.Encode()
} }
@@ -361,6 +706,49 @@ 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 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 { func formatInt64(value int64) string {
negative := value < 0 negative := value < 0
if negative { 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 { if err := os.MkdirAll(cfg.BadgerDir, 0o755); err != nil {
return err return err
} }
if err := os.MkdirAll("data/logs", 0o755); err != nil {
return err
}
benchmarkStore, err := store.Open(cfg.BadgerDir) benchmarkStore, err := store.Open(cfg.BadgerDir)
if err != nil { if err != nil {
@@ -43,10 +46,15 @@ func run(logger *log.Logger) error {
} }
defer closeOnce.Do(closeStore) defer closeOnce.Do(closeStore)
app, err := web.New(benchmarkStore, cfg.PageSize) app, err := web.New(benchmarkStore, cfg.PageSize, "data/logs")
if err != nil { if err != nil {
return err return err
} }
defer func() {
if err := app.Close(); err != nil {
logger.Printf("close error log: %v", err)
}
}()
server := &http.Server{ server := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,

View File

@@ -5,19 +5,52 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CPU Benchmark Submissions</title> <title>CPU Benchmark Submissions</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script> <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> </head>
<body class="min-h-screen bg-slate-100 text-slate-900"> <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"> <div class="bg-slate-950 text-white dark:bg-slate-900">
<div class="mx-auto max-w-7xl px-6 py-10"> <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="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>
</div> </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> <div>Total results: <span class="font-semibold text-white">{{ .TotalCount }}</span></div>
{{ if gt .ShowingTo 0 }} {{ if gt .ShowingTo 0 }}
<div>Showing {{ .ShowingFrom }} to {{ .ShowingTo }}</div> <div>Showing {{ .ShowingFrom }} to {{ .ShowingTo }}</div>
@@ -30,28 +63,87 @@
</div> </div>
<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 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_auto]"> <form method="get" action="/" class="grid gap-4 lg:grid-cols-[2fr_2fr_1fr_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 dark:text-slate-300">General search</span>
<input <input
type="text" type="text"
name="text" name="text"
value="{{ .QueryText }}" value="{{ .QueryText }}"
placeholder="Submitter, score, vendor, features, thread mode" 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>
<label class="block"> <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 <input
type="text" type="text"
name="cpu" name="cpu"
value="{{ .QueryCPU }}" value="{{ .QueryCPU }}"
placeholder="Ryzen 7 5800X, i7-13700K, Xeon" 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>
<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"> <div class="flex gap-3 lg:justify-end">
<button <button
type="submit" type="submit"
@@ -61,7 +153,7 @@
</button> </button>
<a <a
href="/" 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 Clear
</a> </a>
@@ -71,24 +163,24 @@
<section class="mt-8 space-y-4"> <section class="mt-8 space-y-4">
{{ if not .Submissions }} {{ 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. No submissions to display.
</div> </div>
{{ end }} {{ end }}
{{ 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 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20">
<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 dark:text-slate-100">{{ .Submitter }}</p>
<p class="mt-1 text-xs text-slate-500">{{ .SubmissionID }}</p> <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ .SubmissionID }}</p>
</div> </div>
<div> <div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">CPU</p> <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-2 font-semibold text-slate-900 dark:text-slate-100">{{ .CPUInfo.BrandString }}</p>
<p class="mt-1 text-sm text-slate-500">{{ .CPUInfo.VendorID }} • {{ .CPUInfo.PhysicalCores }}C / {{ .CPUInfo.LogicalCores }}T</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>
<div> <div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">Score</p> <p class="text-xs uppercase tracking-[0.25em] text-slate-400">Score</p>
@@ -96,40 +188,68 @@
</div> </div>
<div> <div>
<p class="text-xs uppercase tracking-[0.25em] text-slate-400">MOps/sec</p> <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>
<div> <div>
<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 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>
</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 dark:border-slate-800 dark:bg-slate-950/70">
<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 dark:bg-slate-900">
<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 dark:text-slate-200">{{ formatTime .StartedAt }}</p>
</div> </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="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>
<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="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>
<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">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 dark:text-slate-200">
{{ .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 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>
<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 dark:border-slate-800 dark:bg-slate-900">
<table class="min-w-full divide-y divide-slate-200 text-sm"> <table class="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead class="bg-slate-100 text-slate-600"> <thead class="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<tr> <tr>
<th class="px-4 py-3 text-left font-semibold">Logical ID</th> <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> <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> <th class="px-4 py-3 text-left font-semibold">Total Ops</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
{{ range .CoreResults }} {{ range .CoreResults }}
<tr> <tr>
<td class="px-4 py-3 font-medium text-slate-900">{{ .LogicalID }}</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">{{ .CoreType }}</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">{{ formatFloat .MOpsPerSec }}</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">{{ formatInt64 .TotalOps }}</td> <td class="px-4 py-3 text-slate-700 dark:text-slate-300">{{ formatInt64 .TotalOps }}</td>
</tr> </tr>
{{ else }} {{ else }}
<tr> <tr class="dark:bg-slate-900">
<td colspan="4" class="px-4 py-6 text-center text-slate-500">No per-core results available.</td> <td colspan="4" class="px-4 py-6 text-center text-slate-500 dark:text-slate-400">No per-core results available.</td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>
@@ -159,24 +279,59 @@
</section> </section>
{{ if gt .TotalPages 1 }} {{ 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"> <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"> <div class="text-sm text-slate-500 dark:text-slate-400">
Page {{ .Page }} of {{ .TotalPages }} Page {{ .Page }} of {{ .TotalPages }}
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
{{ if gt .Page 1 }} {{ 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 }} {{ 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 }} {{ end }}
{{ if lt .Page .TotalPages }} {{ 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 }} {{ 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 }} {{ end }}
</div> </div>
</nav> </nav>
{{ end }} {{ end }}
</main> </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> </body>
</html> </html>