From 8d2c7bb8b69bfaeff6d448e20e5eb3ba9e9e408f Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Mon, 2 Mar 2026 15:04:40 -0800 Subject: [PATCH 01/10] Fix .gitignore excluding cmd/cairn-server and cmd/cairn directories The bare patterns "cairn-server" and "cairn" matched the directories under cmd/, preventing them from being tracked. Anchor the patterns to the repo root with a leading "/" so they only ignore the compiled binaries. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 +- cmd/cairn-server/main.go | 85 +++++++++ cmd/cairn/main.go | 395 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 cmd/cairn-server/main.go create mode 100644 cmd/cairn/main.go diff --git a/.gitignore b/.gitignore index ae5a6b9..2bb027a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -cairn-server -cairn +/cairn-server +/cairn *.exe *.test *.out diff --git a/cmd/cairn-server/main.go b/cmd/cairn-server/main.go new file mode 100644 index 0000000..0ad14e6 --- /dev/null +++ b/cmd/cairn-server/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/mattnite/cairn/internal/blob" + "github.com/mattnite/cairn/internal/config" + "github.com/mattnite/cairn/internal/database" + "github.com/mattnite/cairn/internal/forgejo" + "github.com/mattnite/cairn/internal/web" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("Loading config: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + pool, err := database.Connect(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("Connecting to database: %v", err) + } + defer pool.Close() + + if err := database.Migrate(ctx, pool); err != nil { + log.Fatalf("Running migrations: %v", err) + } + + store, err := blob.NewS3Store(cfg.S3Endpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, cfg.S3UseSSL) + if err != nil { + log.Fatalf("Creating blob store: %v", err) + } + + if err := store.EnsureBucket(ctx); err != nil { + log.Fatalf("Ensuring bucket: %v", err) + } + + var forgejoClient *forgejo.Client + if cfg.ForgejoURL != "" && cfg.ForgejoToken != "" { + forgejoClient = forgejo.NewClient(cfg.ForgejoURL, cfg.ForgejoToken) + log.Printf("Forgejo integration enabled: %s", cfg.ForgejoURL) + } + + router, err := web.NewRouter(web.RouterConfig{ + Pool: pool, + Store: store, + ForgejoClient: forgejoClient, + WebhookSecret: cfg.ForgejoWebhookSecret, + }) + if err != nil { + log.Fatalf("Creating router: %v", err) + } + + srv := &http.Server{ + Addr: cfg.ListenAddr, + Handler: router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + log.Println("Shutting down...") + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + srv.Shutdown(shutdownCtx) + }() + + log.Printf("Cairn server listening on %s", cfg.ListenAddr) + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } +} diff --git a/cmd/cairn/main.go b/cmd/cairn/main.go new file mode 100644 index 0000000..9b7b378 --- /dev/null +++ b/cmd/cairn/main.go @@ -0,0 +1,395 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + + switch os.Args[1] { + case "upload": + if err := cmdUpload(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + case "check": + if err := cmdCheck(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + case "campaign": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "usage: cairn campaign \n") + os.Exit(1) + } + if err := cmdCampaign(os.Args[2], os.Args[3:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + case "download": + if err := cmdDownload(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + usage() + os.Exit(1) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `Usage: cairn [args] + +Commands: + upload Upload an artifact to Cairn + check Check for regressions between two commits + campaign start Start a new campaign + campaign finish Finish a running campaign + download Download an artifact + +Upload flags: + -server URL Cairn server URL (default: http://localhost:8080, or CAIRN_SERVER_URL) + -repo NAME Repository name (required) + -owner OWNER Repository owner (required) + -commit SHA Commit SHA (required) + -type TYPE Artifact type: coredump, fuzz, sanitizer, simulation (required) + -file PATH Path to artifact file (required) + -crash-message MSG Crash message (optional) + -stack-trace TRACE Stack trace text (optional) +`) +} + +func cmdUpload(args []string) error { + var ( + serverURL = envOr("CAIRN_SERVER_URL", "http://localhost:8080") + repo string + owner string + commitSHA string + artifactType string + filePath string + crashMessage string + stackTrace string + ) + + for i := 0; i < len(args); i++ { + switch args[i] { + case "-server": + i++ + serverURL = args[i] + case "-repo": + i++ + repo = args[i] + case "-owner": + i++ + owner = args[i] + case "-commit": + i++ + commitSHA = args[i] + case "-type": + i++ + artifactType = args[i] + case "-file": + i++ + filePath = args[i] + case "-crash-message": + i++ + crashMessage = args[i] + case "-stack-trace": + i++ + stackTrace = args[i] + default: + return fmt.Errorf("unknown flag: %s", args[i]) + } + } + + if repo == "" || owner == "" || commitSHA == "" || artifactType == "" || filePath == "" { + return fmt.Errorf("required flags: -repo, -owner, -commit, -type, -file") + } + + meta := map[string]any{ + "repository": repo, + "owner": owner, + "commit_sha": commitSHA, + "type": artifactType, + } + if crashMessage != "" { + meta["crash_message"] = crashMessage + } + if stackTrace != "" { + meta["stack_trace"] = stackTrace + } + + metaJSON, err := json.Marshal(meta) + if err != nil { + return fmt.Errorf("marshaling meta: %w", err) + } + + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer f.Close() + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + if err := w.WriteField("meta", string(metaJSON)); err != nil { + return fmt.Errorf("writing meta field: %w", err) + } + + fw, err := w.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return fmt.Errorf("creating form file: %w", err) + } + + if _, err := io.Copy(fw, f); err != nil { + return fmt.Errorf("copying file: %w", err) + } + + if err := w.Close(); err != nil { + return fmt.Errorf("closing multipart writer: %w", err) + } + + resp, err := http.Post(serverURL+"/api/v1/artifacts", w.FormDataContentType(), &buf) + if err != nil { + return fmt.Errorf("uploading: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("server returned %d: %s", resp.StatusCode, body) + } + + var result map[string]any + json.Unmarshal(body, &result) + fmt.Printf("Artifact uploaded: %s\n", result["id"]) + + return nil +} + +func cmdCheck(args []string) error { + var ( + serverURL = envOr("CAIRN_SERVER_URL", "http://localhost:8080") + repo string + baseSHA string + headSHA string + ) + + for i := 0; i < len(args); i++ { + switch args[i] { + case "-server": + i++ + serverURL = args[i] + case "-repo": + i++ + repo = args[i] + case "-base": + i++ + baseSHA = args[i] + case "-head": + i++ + headSHA = args[i] + default: + return fmt.Errorf("unknown flag: %s", args[i]) + } + } + + if repo == "" || baseSHA == "" || headSHA == "" { + return fmt.Errorf("required flags: -repo, -base, -head") + } + + body := map[string]string{ + "repository": repo, + "base_sha": baseSHA, + "head_sha": headSHA, + } + bodyJSON, _ := json.Marshal(body) + + resp, err := http.Post(serverURL+"/api/v1/regression/check", "application/json", bytes.NewReader(bodyJSON)) + if err != nil { + return fmt.Errorf("checking regression: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned %d: %s", resp.StatusCode, respBody) + } + + var result struct { + IsRegression bool `json:"is_regression"` + New []string `json:"new"` + Fixed []string `json:"fixed"` + Recurring []string `json:"recurring"` + } + json.Unmarshal(respBody, &result) + + if result.IsRegression { + fmt.Printf("REGRESSION: %d new crash signature(s)\n", len(result.New)) + for _, fp := range result.New { + fmt.Printf(" new: %s\n", fp[:16]) + } + os.Exit(1) + } + + fmt.Printf("OK: %d recurring, %d fixed, 0 new\n", len(result.Recurring), len(result.Fixed)) + return nil +} + +func cmdCampaign(subcmd string, args []string) error { + serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080") + + switch subcmd { + case "start": + var repo, owner, name, ctype string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-server": + i++ + serverURL = args[i] + case "-repo": + i++ + repo = args[i] + case "-owner": + i++ + owner = args[i] + case "-name": + i++ + name = args[i] + case "-type": + i++ + ctype = args[i] + default: + return fmt.Errorf("unknown flag: %s", args[i]) + } + } + if repo == "" || owner == "" || name == "" || ctype == "" { + return fmt.Errorf("required flags: -repo, -owner, -name, -type") + } + body, _ := json.Marshal(map[string]string{ + "repository": repo, "owner": owner, "name": name, "type": ctype, + }) + resp, err := http.Post(serverURL+"/api/v1/campaigns", "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("server returned %d: %s", resp.StatusCode, respBody) + } + var result map[string]any + json.Unmarshal(respBody, &result) + fmt.Printf("Campaign started: %s\n", result["id"]) + + case "finish": + var id string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-server": + i++ + serverURL = args[i] + case "-id": + i++ + id = args[i] + default: + return fmt.Errorf("unknown flag: %s", args[i]) + } + } + if id == "" { + return fmt.Errorf("required flag: -id") + } + resp, err := http.Post(serverURL+"/api/v1/campaigns/"+id+"/finish", "application/json", nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned %d: %s", resp.StatusCode, body) + } + fmt.Println("Campaign finished") + + default: + return fmt.Errorf("unknown campaign subcommand: %s (use start or finish)", subcmd) + } + return nil +} + +func cmdDownload(args []string) error { + serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080") + var id, output string + + for i := 0; i < len(args); i++ { + switch args[i] { + case "-server": + i++ + serverURL = args[i] + case "-id": + i++ + id = args[i] + case "-o": + i++ + output = args[i] + default: + return fmt.Errorf("unknown flag: %s", args[i]) + } + } + + if id == "" { + return fmt.Errorf("required flag: -id") + } + + resp, err := http.Get(serverURL + "/api/v1/artifacts/" + id + "/download") + if err != nil { + return fmt.Errorf("downloading: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned %d: %s", resp.StatusCode, body) + } + + var w *os.File + if output == "" || output == "-" { + w = os.Stdout + } else { + w, err = os.Create(output) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + defer w.Close() + } + + n, err := io.Copy(w, resp.Body) + if err != nil { + return fmt.Errorf("writing: %w", err) + } + + if output != "" && output != "-" { + fmt.Fprintf(os.Stderr, "Downloaded %d bytes to %s\n", n, output) + } + return nil +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} From 8dab439a544c51ebadee49d86603c0359ca4dcde Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Mon, 2 Mar 2026 16:13:26 -0800 Subject: [PATCH 02/10] Add readme --- README.md | 220 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac2a7d2 --- /dev/null +++ b/README.md @@ -0,0 +1,220 @@ +# Cairn + +Crash artifact aggregator and regression detection system. Collects, fingerprints, and analyzes crash artifacts across repositories. Provides a CLI client, REST API, and web dashboard. + +## Architecture + +- **Go server** (Gin) with embedded web UI +- **PostgreSQL** for persistent storage with auto-migrations +- **S3-compatible blob storage** (MinIO) for artifact files +- **Optional Forgejo integration** for issue tracking and commit statuses +- **Multi-format crash fingerprinting** — ASan, GDB/LLDB, Zig, and generic stack traces + +## Quick Start + +Start PostgreSQL and MinIO: + +```sh +docker-compose up -d +``` + +Run the server: + +```sh +go run ./cmd/cairn-server +``` + +Visit `http://localhost:8080`. + +## Configuration + +All configuration is via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `CAIRN_LISTEN_ADDR` | `:8080` | HTTP server listen address | +| `CAIRN_DATABASE_URL` | `postgres://cairn:cairn@localhost:5432/cairn?sslmode=disable` | PostgreSQL connection URL | +| `CAIRN_S3_ENDPOINT` | `localhost:9000` | S3/MinIO endpoint | +| `CAIRN_S3_BUCKET` | `cairn-artifacts` | S3 bucket name | +| `CAIRN_S3_ACCESS_KEY` | `minioadmin` | S3 access key | +| `CAIRN_S3_SECRET_KEY` | `minioadmin` | S3 secret key | +| `CAIRN_S3_USE_SSL` | `false` | Enable SSL for S3 | +| `CAIRN_FORGEJO_URL` | *(empty)* | Forgejo base URL | +| `CAIRN_FORGEJO_TOKEN` | *(empty)* | Forgejo API token | +| `CAIRN_FORGEJO_WEBHOOK_SECRET` | *(empty)* | Secret for webhook HMAC verification | + +## CLI + +The `cairn` CLI communicates with the server over HTTP. All commands accept `-server URL` (env: `CAIRN_SERVER_URL`, default: `http://localhost:8080`). + +### Upload an artifact + +```sh +cairn upload \ + -repo myproject -owner myorg -commit abc123 \ + -type sanitizer -file crash.log \ + -crash-message "heap-buffer-overflow" \ + -stack-trace "$(cat trace.txt)" +``` + +Flags: + +| Flag | Required | Description | +|------|----------|-------------| +| `-repo` | yes | Repository name | +| `-owner` | yes | Repository owner | +| `-commit` | yes | Commit SHA | +| `-type` | yes | `coredump`, `fuzz`, `sanitizer`, or `simulation` | +| `-file` | yes | Path to artifact file | +| `-crash-message` | no | Crash message text | +| `-stack-trace` | no | Stack trace text | + +### Check for regressions + +```sh +cairn check -repo myproject -base abc123 -head def456 +``` + +Exits with code 1 if regressions are found. Output: + +```json +{ + "is_regression": true, + "new": [""], + "fixed": [""], + "recurring": [""] +} +``` + +### Campaigns + +```sh +# Start a campaign +cairn campaign start -repo myproject -owner myorg -name "nightly-fuzz" -type fuzz + +# Finish a campaign +cairn campaign finish -id +``` + +### Download an artifact + +```sh +cairn download -id -o output.bin +``` + +Omit `-o` to write to stdout. + +## API Reference + +All endpoints are under `/api/v1`. + +### Artifacts + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/artifacts` | Ingest artifact (multipart: `meta` JSON + `file`) | +| `GET` | `/artifacts` | List artifacts (`?repository_id=&commit_sha=&type=&limit=&offset=`) | +| `GET` | `/artifacts/:id` | Get artifact details | +| `GET` | `/artifacts/:id/download` | Download artifact file | + +### Crash Groups + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/crashgroups` | List crash groups (`?repository_id=&status=&limit=&offset=`) | +| `GET` | `/crashgroups/:id` | Get crash group details | + +### Regression + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/regression/check` | Check regressions (`{repository, base_sha, head_sha}`) | + +### Campaigns + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/campaigns` | Create campaign (`{repository, owner, name, type}`) | +| `GET` | `/campaigns` | List campaigns (`?repository_id=&limit=&offset=`) | +| `GET` | `/campaigns/:id` | Get campaign details | +| `POST` | `/campaigns/:id/finish` | Finish a campaign | + +### Other + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/dashboard` | Dashboard statistics, trends, top crashers | +| `GET` | `/search` | Full-text search (`?q=&limit=&offset=`) | +| `POST` | `/webhooks/forgejo` | Forgejo webhook receiver | + +## Crash Fingerprinting + +The fingerprinting pipeline turns raw crash output into a stable identifier for grouping duplicate crashes. + +### Pipeline + +1. **Parse** — Extract stack frames from raw text. Parsers are tried in order: ASan → GDB/LLDB → Zig → Generic. +2. **Normalize** — Strip addresses, template parameters, ABI tags. Filter runtime/library frames. Keep the top 8 frames. +3. **Hash** — Combine normalized function names and file names, then SHA-256 hash to produce a 64-character hex fingerprint. + +### Supported Formats + +| Parser | Detects | Example Pattern | +|--------|---------|-----------------| +| ASan/MSan/TSan/UBSan | `==PID==ERROR: AddressSanitizer` | `#0 0x55a3b4 in func /file.c:10:5` | +| GDB/LLDB | Presence of `#0` frame marker | `#0 0x55a3b4 in func () at /file.c:10` | +| Zig | `panic:` keyword | `/file.zig:10:5: 0x55a3b4 in func (module)` | +| Generic | Heuristic fallback | `at func (file.c:10)` or `func+0x1a` | + +### Normalization Rules + +- Strip hex addresses +- Replace C++ template parameters (`vector` → `vector<>`) +- Strip ABI tags (`[abi:cxx11]`) +- Extract file basenames only +- Filter runtime prefixes: `__libc_`, `__asan_`, `__sanitizer_`, `std.debug.`, etc. + +## Forgejo Integration + +When `CAIRN_FORGEJO_URL` and `CAIRN_FORGEJO_TOKEN` are set, Cairn integrates with Forgejo: + +- **Issue creation** — New crash signatures automatically open issues prefixed with `[Cairn]` +- **Issue sync** — Closing/reopening a `[Cairn]` issue in Forgejo updates the crash group status in Cairn +- **Commit statuses** — Regression checks post `cairn/regression` status (success/failure) to commits +- **Webhooks** — Configure a Forgejo webhook pointing to `/api/v1/webhooks/forgejo` with the shared secret. Handles `issues` and `push` events. Verifies HMAC-SHA256 signatures via `X-Forgejo-Signature` (or `X-Gitea-Signature`). + +## Deployment + +Infrastructure is managed as a Nomad service job in `../infra/cairn/cairn.hcl`. + +The Dockerfile uses a multi-stage build: + +```dockerfile +# Build stage — Go 1.25 Alpine, static binary (CGO_ENABLED=0) +FROM golang:1.25-alpine AS builder +# ... +RUN CGO_ENABLED=0 go build -o /cairn-server ./cmd/cairn-server + +# Runtime stage — minimal Alpine with ca-certificates and tzdata +FROM alpine:3.21 +COPY --from=builder /cairn-server /usr/local/bin/cairn-server +ENTRYPOINT ["cairn-server"] +``` + +The server reads all configuration from environment variables at startup. + +## Database + +The schema auto-migrates on startup. Core tables: + +| Table | Purpose | +|-------|---------| +| `repositories` | Tracked repositories (name, owner, optional Forgejo URL) | +| `commits` | Commit SHAs linked to repositories | +| `builds` | Optional build metadata (builder, flags, tags) | +| `artifacts` | Crash artifacts with blob references, metadata, and full-text search | +| `crash_signatures` | Unique fingerprints per repository with occurrence counts | +| `crash_groups` | Human-facing crash groups linked to signatures and Forgejo issues | +| `campaigns` | Testing campaigns (running/finished) grouping artifacts | + +Artifacts have a GIN-indexed `tsvector` column for full-text search across crash messages, stack traces, and types. From bbdfd34519492e57ccf157e7f3f2b9ab5c16efb4 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Mon, 2 Mar 2026 16:20:25 -0800 Subject: [PATCH 03/10] dark fantasy --- web/static/css/cairn.css | 294 +++++++++++++++++++++++++++++++------- web/templates/layout.html | 17 ++- 2 files changed, 253 insertions(+), 58 deletions(-) diff --git a/web/static/css/cairn.css b/web/static/css/cairn.css index 8a76387..16f6924 100644 --- a/web/static/css/cairn.css +++ b/web/static/css/cairn.css @@ -1,18 +1,26 @@ +@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Fira+Code:wght@400;500&display=swap'); + :root { - --bg: #0f1117; - --bg-surface: #1a1d27; - --bg-hover: #242836; - --border: #2a2e3d; - --text: #e1e4ed; - --text-muted: #8b90a0; - --accent: #6c8cff; - --accent-hover: #8ba4ff; - --danger: #ff6b6b; - --warning: #ffd666; - --success: #69db7c; - --sidebar-width: 220px; - --radius: 6px; - --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + --bg: #0a0709; + --bg-surface: #151015; + --bg-hover: #1f1520; + --border: #2d1a28; + --border-glow: #4a1530; + --text: #d4c5cc; + --text-muted: #7a6670; + --accent: #e83050; + --accent-hover: #ff4d6a; + --accent-glow: rgba(232, 48, 80, 0.4); + --accent-glow-soft: rgba(232, 48, 80, 0.15); + --danger: #ff3344; + --warning: #c4943a; + --success: #4a8c5c; + --bone: #c4b5a0; + --bone-dim: #7a6e5f; + --sidebar-width: 240px; + --radius: 4px; + --font-heading: 'Cinzel', 'Georgia', serif; + --font-mono: 'Fira Code', 'SF Mono', 'Cascadia Code', monospace; } * { margin: 0; padding: 0; box-sizing: border-box; } @@ -35,23 +43,71 @@ body { top: 0; bottom: 0; overflow-y: auto; + box-shadow: 2px 0 20px rgba(0, 0, 0, 0.5); } -.sidebar-header { padding: 0 1.25rem 1.5rem; } -.logo { font-size: 1.25rem; font-weight: 700; color: var(--accent); } - -.nav-links { list-style: none; } -.nav-links a { +.sidebar-header { + padding: 0 1.25rem 1rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.5rem; +} +.logo { + font-family: var(--font-heading); + font-size: 1.5rem; + font-weight: 700; + color: var(--accent); + text-shadow: 0 0 20px var(--accent-glow), 0 0 40px rgba(232, 48, 80, 0.2); + letter-spacing: 0.15em; + text-transform: uppercase; +} +.logo-skull { display: block; + font-size: 2rem; + line-height: 1; + margin-bottom: 0.25rem; + filter: drop-shadow(0 0 8px var(--accent-glow)); +} + +.nav-links { list-style: none; padding-top: 0.5rem; } +.nav-links li { position: relative; } +.nav-links a { + display: flex; + align-items: center; + gap: 0.75rem; padding: 0.625rem 1.25rem; color: var(--text-muted); text-decoration: none; - font-size: 0.875rem; - transition: all 0.15s; + font-size: 0.85rem; + transition: all 0.2s; + border-left: 2px solid transparent; } .nav-links a:hover { - color: var(--text); + color: var(--accent-hover); background: var(--bg-hover); + border-left-color: var(--accent); + text-shadow: 0 0 12px var(--accent-glow); +} +.nav-icon { + font-size: 1rem; + width: 1.5rem; + text-align: center; + filter: grayscale(0.3); +} + +.sidebar-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.75rem 1.25rem; +} + +.sidebar-footer { + padding: 1rem 1.25rem; + margin-top: auto; + border-top: 1px solid var(--border); + font-size: 0.7rem; + color: var(--bone-dim); + font-family: var(--font-heading); + letter-spacing: 0.08em; } /* Main content */ @@ -67,7 +123,13 @@ body { padding-bottom: 1rem; border-bottom: 1px solid var(--border); } -.page-header h2 { font-size: 1.5rem; font-weight: 600; } +.page-header h2 { + font-family: var(--font-heading); + font-size: 1.35rem; + font-weight: 600; + color: var(--bone); + letter-spacing: 0.06em; +} /* Stats */ .stats-row { @@ -84,9 +146,28 @@ body { display: flex; flex-direction: column; min-width: 160px; + position: relative; + overflow: hidden; + transition: border-color 0.3s, box-shadow 0.3s; +} +.stat-card:hover { + border-color: var(--border-glow); + box-shadow: 0 0 15px var(--accent-glow-soft), inset 0 0 15px rgba(232, 48, 80, 0.05); +} +.stat-value { + font-family: var(--font-heading); + font-size: 2rem; + font-weight: 700; + color: var(--accent); + text-shadow: 0 0 15px var(--accent-glow); +} +.stat-label { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.08em; } -.stat-value { font-size: 2rem; font-weight: 700; color: var(--accent); } -.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem; } /* Tables */ .table { @@ -97,18 +178,22 @@ body { .table th { text-align: left; padding: 0.75rem 1rem; - border-bottom: 2px solid var(--border); - color: var(--text-muted); - font-weight: 500; - font-size: 0.75rem; + border-bottom: 2px solid var(--border-glow); + color: var(--bone-dim); + font-family: var(--font-heading); + font-weight: 600; + font-size: 0.7rem; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.1em; } .table td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); } -.table tr:hover td { background: var(--bg-hover); } +.table tr:hover td { + background: var(--bg-hover); + box-shadow: inset 2px 0 0 var(--accent); +} /* Badges */ .badge { @@ -118,26 +203,52 @@ body { font-size: 0.75rem; font-weight: 600; font-family: var(--font-mono); + border: 1px solid transparent; +} +.badge-coredump { + background: rgba(255, 51, 68, 0.12); + color: var(--danger); + border-color: rgba(255, 51, 68, 0.25); + text-shadow: 0 0 8px rgba(255, 51, 68, 0.3); +} +.badge-fuzz { + background: rgba(74, 140, 92, 0.12); + color: var(--success); + border-color: rgba(74, 140, 92, 0.25); +} +.badge-sanitizer { + background: rgba(196, 148, 58, 0.12); + color: var(--warning); + border-color: rgba(196, 148, 58, 0.25); +} +.badge-simulation { + background: rgba(232, 48, 80, 0.1); + color: var(--accent-hover); + border-color: rgba(232, 48, 80, 0.2); } -.badge-coredump { background: #3b2a2a; color: var(--danger); } -.badge-fuzz { background: #2a3b2a; color: var(--success); } -.badge-sanitizer { background: #3b3b2a; color: var(--warning); } -.badge-simulation { background: #2a2a3b; color: var(--accent); } /* Buttons */ .btn { display: inline-block; padding: 0.5rem 1rem; background: var(--bg-surface); - color: var(--text); + color: var(--bone); border: 1px solid var(--border); border-radius: var(--radius); - font-size: 0.875rem; + font-size: 0.85rem; text-decoration: none; cursor: pointer; - transition: all 0.15s; + transition: all 0.2s; + font-family: var(--font-heading); + letter-spacing: 0.04em; +} +.btn:hover { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--accent-hover); + box-shadow: 0 0 12px var(--accent-glow-soft); + text-shadow: 0 0 8px var(--accent-glow); } -.btn:hover { background: var(--bg-hover); border-color: var(--accent); } .btn-sm { padding: 0.25rem 0.625rem; font-size: 0.8rem; } /* Code */ @@ -147,11 +258,13 @@ code { background: var(--bg-hover); padding: 0.15rem 0.35rem; border-radius: 3px; + color: var(--accent-hover); } .code-block { background: var(--bg-surface); border: 1px solid var(--border); + border-left: 3px solid var(--accent); border-radius: var(--radius); padding: 1rem; overflow-x: auto; @@ -160,6 +273,7 @@ code { line-height: 1.6; white-space: pre-wrap; word-break: break-all; + box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.3); } /* Detail pages */ @@ -182,13 +296,18 @@ code { border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; + transition: border-color 0.3s; +} +.detail-item:hover { + border-color: var(--border-glow); } .detail-item label { display: block; - font-size: 0.75rem; - color: var(--text-muted); + font-family: var(--font-heading); + font-size: 0.7rem; + color: var(--bone-dim); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.08em; margin-bottom: 0.375rem; } @@ -196,7 +315,13 @@ code { /* Sections */ .section { margin-bottom: 2rem; } -.section h3 { margin-bottom: 0.75rem; font-size: 1rem; } +.section h3 { + margin-bottom: 0.75rem; + font-family: var(--font-heading); + font-size: 1rem; + color: var(--bone); + letter-spacing: 0.04em; +} /* Utilities */ .empty-state { @@ -204,6 +329,7 @@ code { padding: 3rem; text-align: center; font-size: 0.9rem; + font-style: italic; } .toolbar { @@ -223,12 +349,30 @@ code { } /* Status badges */ -.badge-status-open { background: #3b2a2a; color: var(--danger); } -.badge-status-resolved { background: #2a3b2a; color: var(--success); } +.badge-status-open { + background: rgba(255, 51, 68, 0.12); + color: var(--danger); + border-color: rgba(255, 51, 68, 0.25); + text-shadow: 0 0 8px rgba(255, 51, 68, 0.4); +} +.badge-status-resolved { + background: rgba(74, 140, 92, 0.12); + color: var(--success); + border-color: rgba(74, 140, 92, 0.25); +} /* Campaign badges */ -.badge-campaign-running { background: #2a2a3b; color: var(--accent); } -.badge-campaign-finished { background: #2a3b2a; color: var(--success); } +.badge-campaign-running { + background: rgba(232, 48, 80, 0.1); + color: var(--accent-hover); + border-color: rgba(232, 48, 80, 0.2); + text-shadow: 0 0 8px var(--accent-glow); +} +.badge-campaign-finished { + background: rgba(74, 140, 92, 0.12); + color: var(--success); + border-color: rgba(74, 140, 92, 0.25); +} /* Search */ .search-form { @@ -244,16 +388,20 @@ code { border-radius: var(--radius); color: var(--text); font-size: 0.9rem; + transition: all 0.2s; } .search-input:focus { outline: none; border-color: var(--accent); + box-shadow: 0 0 12px var(--accent-glow-soft); } /* Crash group detail */ .crashgroup-title { + font-family: var(--font-heading); font-size: 1.25rem; margin-bottom: 1.5rem; + color: var(--bone); } /* Regression */ @@ -263,7 +411,27 @@ code { align-items: flex-end; } .form-group { display: flex; flex-direction: column; gap: 0.375rem; flex: 1; } -.form-group label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } +.form-group label { + font-family: var(--font-heading); + font-size: 0.7rem; + color: var(--bone-dim); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.form-group input { + padding: 0.625rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 0.9rem; + transition: all 0.2s; +} +.form-group input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 12px var(--accent-glow-soft); +} .regression-result { border: 1px solid var(--border); @@ -272,9 +440,33 @@ code { margin-top: 1.5rem; } .regression-pass { border-color: var(--success); } -.regression-fail { border-color: var(--danger); } -.regression-verdict { font-size: 1.1rem; margin-bottom: 1rem; } -.regression-fail .regression-verdict { color: var(--danger); } +.regression-fail { + border-color: var(--danger); + box-shadow: 0 0 20px rgba(255, 51, 68, 0.15); +} +.regression-verdict { + font-family: var(--font-heading); + font-size: 1.1rem; + margin-bottom: 1rem; + letter-spacing: 0.04em; +} +.regression-fail .regression-verdict { + color: var(--danger); + text-shadow: 0 0 15px rgba(255, 51, 68, 0.4); +} .regression-pass .regression-verdict { color: var(--success); } .fingerprint-list { list-style: none; padding: 0; } .fingerprint-list li { padding: 0.375rem 0; } + +/* Links */ +a { color: var(--accent); transition: all 0.2s; } +a:hover { + color: var(--accent-hover); + text-shadow: 0 0 8px var(--accent-glow); +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border-glow); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--accent); } diff --git a/web/templates/layout.html b/web/templates/layout.html index bab6275..ef8d554 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -9,17 +9,20 @@