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.
497 lines
12 KiB
Go
497 lines
12 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"cpu-benchmark-server/lib/model"
|
|
"cpu-benchmark-server/lib/store"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
const maxSubmissionBytes = 4 << 20
|
|
|
|
type App struct {
|
|
store *store.Store
|
|
templates *template.Template
|
|
pageSize int
|
|
}
|
|
|
|
type indexPageData struct {
|
|
Submissions []model.Submission
|
|
QueryText string
|
|
QueryCPU string
|
|
QueryThread string
|
|
QueryPlatform string
|
|
QueryIntensity int
|
|
QueryDuration int
|
|
Page int
|
|
TotalPages int
|
|
TotalCount int
|
|
ShowingFrom int
|
|
ShowingTo int
|
|
PrevURL string
|
|
NextURL string
|
|
}
|
|
|
|
type jsonSubmissionEnvelope struct {
|
|
Submitter string `json:"submitter"`
|
|
Platform string `json:"platform"`
|
|
Benchmark *model.BenchmarkResult `json:"benchmark"`
|
|
Result *model.BenchmarkResult `json:"result"`
|
|
Data *model.BenchmarkResult `json:"data"`
|
|
}
|
|
|
|
type flatSubmissionEnvelope struct {
|
|
Submitter string `json:"submitter"`
|
|
Platform string `json:"platform"`
|
|
model.BenchmarkResult
|
|
}
|
|
|
|
type errorResponse struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func New(store *store.Store, pageSize int) (*App, error) {
|
|
funcs := template.FuncMap{
|
|
"formatInt64": formatInt64,
|
|
"formatFloat": formatFloat,
|
|
"formatTime": formatTime,
|
|
"modeLabel": model.ThreadModeLabel,
|
|
}
|
|
|
|
templates, err := template.New("index.html").Funcs(funcs).ParseFiles("templates/index.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &App{
|
|
store: store,
|
|
templates: templates,
|
|
pageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) Routes() http.Handler {
|
|
router := chi.NewRouter()
|
|
router.Use(middleware.RequestID)
|
|
router.Use(middleware.RealIP)
|
|
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/search", a.handleSearch)
|
|
router.Post("/api/submit", a.handleSubmit)
|
|
|
|
return router
|
|
}
|
|
|
|
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
page := parsePositiveInt(r.URL.Query().Get("page"), 1)
|
|
text := strings.TrimSpace(r.URL.Query().Get("text"))
|
|
cpu := strings.TrimSpace(r.URL.Query().Get("cpu"))
|
|
thread := strings.TrimSpace(r.URL.Query().Get("thread"))
|
|
platform := strings.TrimSpace(r.URL.Query().Get("platform"))
|
|
intensity := parsePositiveInt(r.URL.Query().Get("intensity"), 0)
|
|
durationSecs := parsePositiveInt(r.URL.Query().Get("durationSecs"), 0)
|
|
|
|
var (
|
|
submissions []model.Submission
|
|
totalCount int
|
|
)
|
|
|
|
if text != "" || cpu != "" || thread != "" || platform != "" || intensity > 0 || durationSecs > 0 {
|
|
matches := a.store.SearchSubmissions(text, cpu, thread, platform, intensity, durationSecs)
|
|
totalCount = len(matches)
|
|
start, end, normalizedPage := pageBounds(page, a.pageSize, totalCount)
|
|
page = normalizedPage
|
|
submissions = matches[start:end]
|
|
} else {
|
|
var count int
|
|
submissions, count = a.store.ListSubmissions(page, a.pageSize)
|
|
totalCount = count
|
|
_, _, page = pageBounds(page, a.pageSize, totalCount)
|
|
}
|
|
|
|
totalPageCount := totalPages(totalCount, a.pageSize)
|
|
showingFrom, showingTo := visibleBounds(page, a.pageSize, len(submissions), totalCount)
|
|
|
|
data := indexPageData{
|
|
Submissions: submissions,
|
|
QueryText: text,
|
|
QueryCPU: cpu,
|
|
QueryThread: normalizeThreadFilter(thread),
|
|
QueryPlatform: normalizePlatformFilter(platform),
|
|
QueryIntensity: intensity,
|
|
QueryDuration: durationSecs,
|
|
Page: page,
|
|
TotalPages: totalPageCount,
|
|
TotalCount: totalCount,
|
|
ShowingFrom: showingFrom,
|
|
ShowingTo: showingTo,
|
|
PrevURL: buildIndexURL(max(1, page-1), text, cpu, thread, platform, intensity, durationSecs),
|
|
NextURL: buildIndexURL(max(1, min(totalPageCount, page+1)), text, cpu, thread, platform, intensity, durationSecs),
|
|
}
|
|
|
|
if err := a.templates.ExecuteTemplate(w, "index.html", data); err != nil {
|
|
http.Error(w, fmt.Sprintf("render template: %v", err), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
"submissions": a.store.Count(),
|
|
})
|
|
}
|
|
|
|
func (a *App) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
results := a.store.SearchSubmissions(
|
|
r.URL.Query().Get("text"),
|
|
r.URL.Query().Get("cpu"),
|
|
r.URL.Query().Get("thread"),
|
|
r.URL.Query().Get("platform"),
|
|
parsePositiveInt(r.URL.Query().Get("intensity"), 0),
|
|
parsePositiveInt(r.URL.Query().Get("durationSecs"), 0),
|
|
)
|
|
writeJSON(w, http.StatusOK, results)
|
|
}
|
|
|
|
func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxSubmissionBytes)
|
|
|
|
result, submitter, platform, err := parseSubmissionRequest(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := result.Validate(); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
platform = model.NormalizePlatform(platform)
|
|
if err := model.ValidatePlatform(platform); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
submission, err := a.store.SaveSubmission(result, submitter, platform)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: fmt.Sprintf("store submission: %v", err)})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"success": true,
|
|
"submissionID": submission.SubmissionID,
|
|
"submitter": submission.Submitter,
|
|
"platform": submission.Platform,
|
|
"submittedAt": submission.SubmittedAt,
|
|
})
|
|
}
|
|
|
|
func parseSubmissionRequest(r *http.Request) (model.BenchmarkResult, string, string, error) {
|
|
contentType := r.Header.Get("Content-Type")
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil && contentType != "" {
|
|
return model.BenchmarkResult{}, "", "", fmt.Errorf("parse content type: %w", err)
|
|
}
|
|
|
|
switch mediaType {
|
|
case "", "application/json":
|
|
return parseJSONSubmission(r)
|
|
case "multipart/form-data":
|
|
return parseMultipartSubmission(r)
|
|
default:
|
|
return model.BenchmarkResult{}, "", "", fmt.Errorf("unsupported content type %q", mediaType)
|
|
}
|
|
}
|
|
|
|
func parseJSONSubmission(r *http.Request) (model.BenchmarkResult, string, string, error) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return model.BenchmarkResult{}, "", "", fmt.Errorf("read request body: %w", err)
|
|
}
|
|
|
|
submitter := firstNonEmpty(
|
|
r.URL.Query().Get("submitter"),
|
|
r.Header.Get("X-Submitter"),
|
|
)
|
|
platform := firstNonEmpty(
|
|
r.URL.Query().Get("platform"),
|
|
r.Header.Get("X-Platform"),
|
|
)
|
|
|
|
var nested jsonSubmissionEnvelope
|
|
if err := json.Unmarshal(body, &nested); err == nil {
|
|
submitter = firstNonEmpty(nested.Submitter, submitter)
|
|
platform = firstNonEmpty(nested.Platform, platform)
|
|
for _, candidate := range []*model.BenchmarkResult{nested.Benchmark, nested.Result, nested.Data} {
|
|
if candidate != nil {
|
|
return *candidate, submitter, platform, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
var flat flatSubmissionEnvelope
|
|
if err := json.Unmarshal(body, &flat); err != nil {
|
|
return model.BenchmarkResult{}, "", "", fmt.Errorf("decode benchmark JSON: %w", err)
|
|
}
|
|
|
|
return flat.BenchmarkResult, firstNonEmpty(flat.Submitter, submitter), firstNonEmpty(flat.Platform, platform, "windows"), nil
|
|
}
|
|
|
|
func parseMultipartSubmission(r *http.Request) (model.BenchmarkResult, string, string, error) {
|
|
if err := r.ParseMultipartForm(maxSubmissionBytes); err != nil {
|
|
return model.BenchmarkResult{}, "", "", fmt.Errorf("parse multipart form: %w", err)
|
|
}
|
|
|
|
payload, err := readMultipartPayload(r)
|
|
if err != nil {
|
|
return model.BenchmarkResult{}, "", "", err
|
|
}
|
|
|
|
var result model.BenchmarkResult
|
|
if err := json.Unmarshal(payload, &result); err != nil {
|
|
return model.BenchmarkResult{}, "", "", fmt.Errorf("decode benchmark JSON: %w", err)
|
|
}
|
|
|
|
return result, r.FormValue("submitter"), firstNonEmpty(r.FormValue("platform"), "windows"), nil
|
|
}
|
|
|
|
func readMultipartPayload(r *http.Request) ([]byte, error) {
|
|
for _, field := range []string{"benchmark", "file", "benchmarkFile"} {
|
|
file, _, err := r.FormFile(field)
|
|
if err == nil {
|
|
defer file.Close()
|
|
payload, readErr := io.ReadAll(file)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("read multipart benchmark file: %w", readErr)
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
if err != http.ErrMissingFile {
|
|
return nil, fmt.Errorf("read multipart benchmark file: %w", err)
|
|
}
|
|
}
|
|
|
|
for _, field := range []string{"benchmark", "payload", "result", "data"} {
|
|
if value := strings.TrimSpace(r.FormValue(field)); value != "" {
|
|
return []byte(value), nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("multipart request must include benchmark JSON in a file field or text field named benchmark")
|
|
}
|
|
|
|
func parsePositiveInt(raw string, fallback int) int {
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value <= 0 {
|
|
return fallback
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func pageBounds(page, pageSize, total int) (int, int, int) {
|
|
if pageSize <= 0 {
|
|
pageSize = 50
|
|
}
|
|
|
|
totalPages := totalPages(total, pageSize)
|
|
if totalPages == 0 {
|
|
return 0, 0, 1
|
|
}
|
|
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
|
|
if page > totalPages {
|
|
page = totalPages
|
|
}
|
|
|
|
start := (page - 1) * pageSize
|
|
end := min(total, start+pageSize)
|
|
return start, end, page
|
|
}
|
|
|
|
func totalPages(total, pageSize int) int {
|
|
if total == 0 || pageSize <= 0 {
|
|
return 0
|
|
}
|
|
|
|
pages := total / pageSize
|
|
if total%pageSize != 0 {
|
|
pages++
|
|
}
|
|
|
|
return pages
|
|
}
|
|
|
|
func visibleBounds(page, pageSize, visibleCount, total int) (int, int) {
|
|
if total == 0 || visibleCount == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
from := (page-1)*pageSize + 1
|
|
to := from + visibleCount - 1
|
|
return from, to
|
|
}
|
|
|
|
func buildIndexURL(page int, text, cpu, thread, platform string, intensity, durationSecs int) string {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
|
|
values := url.Values{}
|
|
values.Set("page", strconv.Itoa(page))
|
|
if strings.TrimSpace(text) != "" {
|
|
values.Set("text", text)
|
|
}
|
|
if strings.TrimSpace(cpu) != "" {
|
|
values.Set("cpu", cpu)
|
|
}
|
|
if normalizedThread := normalizeThreadFilter(thread); normalizedThread != "" {
|
|
values.Set("thread", normalizedThread)
|
|
}
|
|
if normalizedPlatform := normalizePlatformFilter(platform); normalizedPlatform != "" {
|
|
values.Set("platform", normalizedPlatform)
|
|
}
|
|
if intensity > 0 {
|
|
values.Set("intensity", strconv.Itoa(intensity))
|
|
}
|
|
if durationSecs > 0 {
|
|
values.Set("durationSecs", strconv.Itoa(durationSecs))
|
|
}
|
|
|
|
return "/?" + values.Encode()
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
_ = encoder.Encode(payload)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func normalizeThreadFilter(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "single":
|
|
return "single"
|
|
case "multi":
|
|
return "multi"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizePlatformFilter(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "windows":
|
|
return "windows"
|
|
case "linux":
|
|
return "linux"
|
|
case "macos":
|
|
return "macos"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func formatInt64(value int64) string {
|
|
negative := value < 0
|
|
if negative {
|
|
value = -value
|
|
}
|
|
|
|
digits := strconv.FormatInt(value, 10)
|
|
if len(digits) <= 3 {
|
|
if negative {
|
|
return "-" + digits
|
|
}
|
|
return digits
|
|
}
|
|
|
|
var builder strings.Builder
|
|
if negative {
|
|
builder.WriteByte('-')
|
|
}
|
|
|
|
pre := len(digits) % 3
|
|
if pre > 0 {
|
|
builder.WriteString(digits[:pre])
|
|
if len(digits) > pre {
|
|
builder.WriteByte(',')
|
|
}
|
|
}
|
|
|
|
for i := pre; i < len(digits); i += 3 {
|
|
builder.WriteString(digits[i : i+3])
|
|
if i+3 < len(digits) {
|
|
builder.WriteByte(',')
|
|
}
|
|
}
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
func formatFloat(value float64) string {
|
|
return fmt.Sprintf("%.2f", value)
|
|
}
|
|
|
|
func formatTime(value time.Time) string {
|
|
if value.IsZero() {
|
|
return "-"
|
|
}
|
|
|
|
return value.Format("2006-01-02 15:04:05 MST")
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
|
|
return b
|
|
}
|