diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..3d2ae32 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,151 @@ +name: Deploy + +on: + push: + branches: [main] + +concurrency: + group: deploy-main + cancel-in-progress: false + +jobs: + test: + name: Run Tests + runs-on: x86_64-linux + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.7" + - run: go mod download + - run: go test -v -race -coverprofile=coverage.out ./... + - run: go tool cover -func=coverage.out + + lint: + name: Lint + runs-on: x86_64-linux + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.7" + - run: go vet ./... + - name: Check formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Go files are not formatted:" + gofmt -l . + exit 1 + fi + - name: Check go.mod tidy + run: | + go mod tidy + if [ -n "$(git diff --name-only go.mod go.sum)" ]; then + echo "go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes." + git diff go.mod go.sum + exit 1 + fi + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: latest + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Run govulncheck + run: govulncheck ./... + + deploy: + needs: [test, lint] + runs-on: debian-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.7" + + - name: Compute next version + run: | + LATEST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -n1) + if [ -z "${LATEST_TAG}" ]; then + NEXT="v0.1.0" + else + # Bump patch: v0.1.2 -> v0.1.3 + PREFIX="${LATEST_TAG%.*}" + PATCH="${LATEST_TAG##*.}" + NEXT="${PREFIX}.$((PATCH + 1))" + fi + echo "VERSION=${NEXT}" >> "$GITHUB_ENV" + echo "Next version: ${NEXT}" + + - name: Build server binary + run: CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o ./bin/cairn-server ./cmd/cairn-server + + - name: Cross-compile CLI + run: | + LDFLAGS="-w -s -X main.version=${VERSION}" + for pair in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + GOOS="${pair%/*}" + GOARCH="${pair#*/}" + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \ + go build -ldflags="${LDFLAGS}" -o "./bin/cairn-${GOOS}-${GOARCH}" ./cmd/cairn + done + cp ./bin/cairn-linux-amd64 ./bin/cairn + + - name: Install Docker CLI + run: | + apt-get update && apt-get install -y curl + curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz \ + | tar xz --strip-components=1 -C /usr/local/bin docker/docker + + - name: Wait for Docker + run: | + timeout=30 + elapsed=0 + while ! docker info >/dev/null 2>&1; do + [ $elapsed -ge $timeout ] && echo "Docker not ready" && exit 1 + sleep 2 + elapsed=$((elapsed + 2)) + done + + - name: Build image + run: | + REGISTRY="registry.ts.mattnite.net" + docker build -t "${REGISTRY}/cairn:${GITHUB_SHA}" -t "${REGISTRY}/cairn:latest" . + + - name: Push image + run: | + REGISTRY="registry.ts.mattnite.net" + docker push "${REGISTRY}/cairn:${GITHUB_SHA}" + docker push "${REGISTRY}/cairn:latest" + + - name: Create git tag + run: | + git tag "${VERSION}" + git push origin "${VERSION}" + + - name: Publish CLI to package registry + run: | + PKG="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/cairn" + AUTH="Authorization: token ${{ secrets.PACKAGES_TOKEN }}" + for file in ./bin/cairn-*; do + filename="$(basename "${file}")" + curl -fsSL -X PUT -H "${AUTH}" --upload-file "${file}" "${PKG}/${VERSION}/${filename}" + done + # Overwrite "latest": delete old files then upload + for file in ./bin/cairn-*; do + filename="$(basename "${file}")" + curl -sS -X DELETE -H "${AUTH}" "${PKG}/latest/${filename}" || true + curl -fsSL -X PUT -H "${AUTH}" --upload-file "${file}" "${PKG}/latest/${filename}" + done + + - name: Update infra + uses: https://git.ts.mattnite.net/mattnite/infra/actions/update-image@main + with: + updates: | + cairn ${{ github.sha }} cairn/cairn.hcl + forgejo_token: ${{ secrets.INFRA_API_TOKEN }} diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..fbf6aa3 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,82 @@ +name: Test + +on: + pull_request: + branches: + - main + - master + +jobs: + test: + name: Run Tests + runs-on: x86_64-linux + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.7" + - run: go mod download + - run: go test -v -race -coverprofile=coverage.out ./... + - run: go tool cover -func=coverage.out + + lint: + name: Lint + runs-on: x86_64-linux + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.7" + - run: go vet ./... + - name: Check formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Go files are not formatted:" + gofmt -l . + exit 1 + fi + - name: Check go.mod tidy + run: | + go mod tidy + if [ -n "$(git diff --name-only go.mod go.sum)" ]; then + echo "go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes." + git diff go.mod go.sum + exit 1 + fi + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: latest + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Run govulncheck + run: govulncheck ./... + + build: + name: Build + runs-on: debian-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.7" + - name: Build Go binaries + run: | + CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o ./bin/cairn-server ./cmd/cairn-server + CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o ./bin/cairn ./cmd/cairn + - name: Install Docker CLI + run: | + apt-get update && apt-get install -y curl + curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz \ + | tar xz --strip-components=1 -C /usr/local/bin docker/docker + - name: Wait for Docker + run: | + timeout=30 + elapsed=0 + while ! docker info >/dev/null 2>&1; do + [ $elapsed -ge $timeout ] && echo "Docker not ready" && exit 1 + sleep 2 + elapsed=$((elapsed + 2)) + done + - name: Build Docker image + run: docker build -t cairn:test . 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/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..508cfdd --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,6 @@ +version: "2" + +linters: + exclusions: + presets: + - std-error-handling 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. diff --git a/cmd/cairn-server/main.go b/cmd/cairn-server/main.go new file mode 100644 index 0000000..a02a939 --- /dev/null +++ b/cmd/cairn-server/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "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() { + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + + cfg, err := config.Load() + if err != nil { + log.Fatal().Err(err).Msg("Loading config") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + pool, err := database.Connect(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatal().Err(err).Msg("Connecting to database") + } + defer pool.Close() + + if err := database.Migrate(ctx, pool); err != nil { + log.Fatal().Err(err).Msg("Running migrations") + } + + store, err := blob.NewS3Store(cfg.S3Endpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, cfg.S3UseSSL) + if err != nil { + log.Fatal().Err(err).Msg("Creating blob store") + } + + if err := store.EnsureBucket(ctx); err != nil { + log.Fatal().Err(err).Msg("Ensuring bucket") + } + + var forgejoClient *forgejo.Client + if cfg.ForgejoURL != "" && cfg.ForgejoToken != "" { + forgejoClient = forgejo.NewClient(cfg.ForgejoURL, cfg.ForgejoToken) + log.Info().Str("url", cfg.ForgejoURL).Msg("Forgejo integration enabled") + } + + router, err := web.NewRouter(web.RouterConfig{ + Pool: pool, + Store: store, + ForgejoClient: forgejoClient, + WebhookSecret: cfg.ForgejoWebhookSecret, + }) + if err != nil { + log.Fatal().Err(err).Msg("Creating router") + } + + 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.Info().Msg("Shutting down...") + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("Shutdown error") + } + }() + + log.Info().Str("addr", cfg.ListenAddr).Msg("Cairn server listening") + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal().Err(err).Msg("Server error") + } +} diff --git a/cmd/cairn/main.go b/cmd/cairn/main.go new file mode 100644 index 0000000..b39c753 --- /dev/null +++ b/cmd/cairn/main.go @@ -0,0 +1,426 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" +) + +var version = "dev" + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + + switch os.Args[1] { + case "version": + fmt.Println(version) + return + 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) + -seed VALUE Simulation seed for reproducibility (optional, stored in metadata) + -target NAME Target name/platform (optional, stored in metadata) +`) +} + +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 + seed string + target 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] + case "-seed": + i++ + seed = args[i] + case "-target": + i++ + target = 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 + } + md := map[string]any{} + if seed != "" { + md["seed"] = seed + } + if target != "" { + md["target"] = target + } + if len(md) > 0 { + meta["metadata"] = md + } + + 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 + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + 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"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + 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 + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + 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 +} diff --git a/go.mod b/go.mod index b8a7975..81de62c 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,12 @@ module github.com/mattnite/cairn -go 1.25.3 +go 1.25.7 require ( github.com/gin-gonic/gin v1.12.0 github.com/jackc/pgx/v5 v5.8.0 github.com/minio/minio-go/v7 v7.0.98 + github.com/rs/zerolog v1.34.0 ) require ( @@ -31,6 +32,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect diff --git a/go.sum b/go.sum index af5a7b8..0cba481 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -31,6 +32,7 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -59,6 +61,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= @@ -76,6 +82,7 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -86,6 +93,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -118,7 +127,9 @@ golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= diff --git a/internal/config/config.go b/internal/config/config.go index 6ad1931..7cff110 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,20 +17,20 @@ type Config struct { S3SecretKey string S3UseSSL bool - ForgejoURL string - ForgejoToken string + ForgejoURL string + ForgejoToken string ForgejoWebhookSecret string } func Load() (*Config, error) { c := &Config{ - ListenAddr: envOr("CAIRN_LISTEN_ADDR", ":8080"), - DatabaseURL: envOr("CAIRN_DATABASE_URL", "postgres://cairn:cairn@localhost:5432/cairn?sslmode=disable"), - S3Endpoint: envOr("CAIRN_S3_ENDPOINT", "localhost:9000"), - S3Bucket: envOr("CAIRN_S3_BUCKET", "cairn-artifacts"), - S3AccessKey: envOr("CAIRN_S3_ACCESS_KEY", "minioadmin"), - S3SecretKey: envOr("CAIRN_S3_SECRET_KEY", "minioadmin"), - S3UseSSL: envBool("CAIRN_S3_USE_SSL", false), + ListenAddr: envOr("CAIRN_LISTEN_ADDR", ":8080"), + DatabaseURL: envOr("CAIRN_DATABASE_URL", "postgres://cairn:cairn@localhost:5432/cairn?sslmode=disable"), + S3Endpoint: envOr("CAIRN_S3_ENDPOINT", "localhost:9000"), + S3Bucket: envOr("CAIRN_S3_BUCKET", "cairn-artifacts"), + S3AccessKey: envOr("CAIRN_S3_ACCESS_KEY", "minioadmin"), + S3SecretKey: envOr("CAIRN_S3_SECRET_KEY", "minioadmin"), + S3UseSSL: envBool("CAIRN_S3_USE_SSL", false), ForgejoURL: envOr("CAIRN_FORGEJO_URL", ""), ForgejoToken: envOr("CAIRN_FORGEJO_TOKEN", ""), ForgejoWebhookSecret: envOr("CAIRN_FORGEJO_WEBHOOK_SECRET", ""), diff --git a/internal/database/migrate.go b/internal/database/migrate.go index f64ac8e..6dbb60d 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -5,11 +5,11 @@ import ( "embed" "fmt" "io/fs" - "log" "sort" "strings" "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" ) //go:embed migrations/*.sql @@ -63,12 +63,12 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error { } if _, err := tx.Exec(ctx, string(sql)); err != nil { - tx.Rollback(ctx) + _ = tx.Rollback(ctx) return fmt.Errorf("executing migration %s: %w", version, err) } if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil { - tx.Rollback(ctx) + _ = tx.Rollback(ctx) return fmt.Errorf("recording migration %s: %w", version, err) } @@ -76,7 +76,7 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error { return fmt.Errorf("committing migration %s: %w", version, err) } - log.Printf("Applied migration: %s", version) + log.Info().Str("version", version).Msg("Applied migration") } return nil diff --git a/internal/fingerprint/normalize.go b/internal/fingerprint/normalize.go index a593a5a..4533b2f 100644 --- a/internal/fingerprint/normalize.go +++ b/internal/fingerprint/normalize.go @@ -9,9 +9,9 @@ import ( const maxFrames = 8 var ( - hexAddrRe = regexp.MustCompile(`0x[0-9a-fA-F]+`) + hexAddrRe = regexp.MustCompile(`0x[0-9a-fA-F]+`) templateParamRe = regexp.MustCompile(`<[^>]*>`) - abiTagRe = regexp.MustCompile(`\[abi:[^\]]*\]`) + abiTagRe = regexp.MustCompile(`\[abi:[^\]]*\]`) ) // runtimePrefixes are function prefixes for runtime/library frames to filter out. diff --git a/internal/fingerprint/parser_asan.go b/internal/fingerprint/parser_asan.go index 3a25a49..ed4ce87 100644 --- a/internal/fingerprint/parser_asan.go +++ b/internal/fingerprint/parser_asan.go @@ -7,15 +7,17 @@ import ( ) // ASan/MSan/TSan/UBSan frame patterns: -// #0 0x55a3b4 in function_name /path/to/file.c:42:13 -// #0 0x55a3b4 in function_name (/path/to/binary+0x1234) -// #1 0x55a3b4 (/path/to/binary+0x1234) +// +// #0 0x55a3b4 in function_name /path/to/file.c:42:13 +// #0 0x55a3b4 in function_name (/path/to/binary+0x1234) +// #1 0x55a3b4 (/path/to/binary+0x1234) var asanFrameRe = regexp.MustCompile( `^\s*#(\d+)\s+(0x[0-9a-fA-F]+)\s+(?:in\s+(\S+)\s+)?(.*)$`, ) // ASan error header line, e.g.: -// ==12345==ERROR: AddressSanitizer: heap-buffer-overflow +// +// ==12345==ERROR: AddressSanitizer: heap-buffer-overflow var asanHeaderRe = regexp.MustCompile( `==\d+==ERROR:\s+(Address|Memory|Thread|Undefined)Sanitizer`, ) diff --git a/internal/fingerprint/parser_gdb.go b/internal/fingerprint/parser_gdb.go index eeb0054..c3db002 100644 --- a/internal/fingerprint/parser_gdb.go +++ b/internal/fingerprint/parser_gdb.go @@ -7,9 +7,10 @@ import ( ) // GDB backtrace frame patterns: -// #0 function_name (args) at /path/to/file.c:42 -// #0 0x00007fff in function_name () from /lib/libfoo.so -// #0 0x00007fff in ?? () +// +// #0 function_name (args) at /path/to/file.c:42 +// #0 0x00007fff in function_name () from /lib/libfoo.so +// #0 0x00007fff in ?? () var gdbFrameRe = regexp.MustCompile( `^\s*#(\d+)\s+(?:(0x[0-9a-fA-F]+)\s+in\s+)?(\S+)\s*\(([^)]*)\)\s*(?:at\s+(\S+?)(?::(\d+))?)?(?:\s+from\s+(\S+))?`, ) diff --git a/internal/fingerprint/parser_zig.go b/internal/fingerprint/parser_zig.go index bec60b0..cc5ad13 100644 --- a/internal/fingerprint/parser_zig.go +++ b/internal/fingerprint/parser_zig.go @@ -7,8 +7,9 @@ import ( ) // Zig panic/stack trace patterns: -// /path/to/file.zig:42:13: 0x1234 in function_name (module) -// ???:?:?: 0x1234 in ??? (???) +// +// /path/to/file.zig:42:13: 0x1234 in function_name (module) +// ???:?:?: 0x1234 in ??? (???) var zigFrameRe = regexp.MustCompile( `^\s*(.+?):(\d+):\d+:\s+(0x[0-9a-fA-F]+)\s+in\s+(\S+)\s+\(([^)]*)\)`, ) diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go index 3892eef..10b36f6 100644 --- a/internal/forgejo/client.go +++ b/internal/forgejo/client.go @@ -38,9 +38,9 @@ type Issue struct { // CreateIssueRequest is the body for creating a Forgejo issue. type CreateIssueRequest struct { - Title string `json:"title"` - Body string `json:"body"` - Labels []int64 `json:"labels,omitempty"` + Title string `json:"title"` + Body string `json:"body"` + Labels []int64 `json:"labels,omitempty"` } // CommitStatus represents a Forgejo commit status. @@ -124,7 +124,7 @@ func (c *Client) do(ctx context.Context, method, path string, body any) (*http.R if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() return nil, fmt.Errorf("forgejo API %s %s: %d %s", method, path, resp.StatusCode, string(respBody)) } @@ -149,6 +149,6 @@ func (c *Client) patch(ctx context.Context, path string, body any) error { if err != nil { return err } - resp.Body.Close() + _ = resp.Body.Close() return nil } diff --git a/internal/forgejo/sync.go b/internal/forgejo/sync.go index 014c573..0171402 100644 --- a/internal/forgejo/sync.go +++ b/internal/forgejo/sync.go @@ -3,10 +3,11 @@ package forgejo import ( "context" "fmt" - "log" "strings" "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" + "github.com/mattnite/cairn/internal/models" ) @@ -80,5 +81,5 @@ func (s *Sync) HandlePushEvent(ctx context.Context, event *WebhookEvent) { if event.Repo == nil || event.After == "" { return } - log.Printf("Push event: %s -> %s", event.Repo.FullName, event.After[:8]) + log.Info().Str("repo", event.Repo.FullName).Str("sha", event.After[:8]).Msg("Push event") } diff --git a/internal/forgejo/webhooks.go b/internal/forgejo/webhooks.go index 08d2638..c26648d 100644 --- a/internal/forgejo/webhooks.go +++ b/internal/forgejo/webhooks.go @@ -12,13 +12,13 @@ import ( // WebhookEvent is the parsed payload from a Forgejo webhook. type WebhookEvent struct { - Action string `json:"action"` - Issue *WebhookIssue `json:"issue,omitempty"` - Repo *WebhookRepo `json:"repository,omitempty"` - Sender *WebhookUser `json:"sender,omitempty"` - Ref string `json:"ref,omitempty"` - After string `json:"after,omitempty"` - Before string `json:"before,omitempty"` + Action string `json:"action"` + Issue *WebhookIssue `json:"issue,omitempty"` + Repo *WebhookRepo `json:"repository,omitempty"` + Sender *WebhookUser `json:"sender,omitempty"` + Ref string `json:"ref,omitempty"` + After string `json:"after,omitempty"` + Before string `json:"before,omitempty"` } type WebhookIssue struct { diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 860ee46..05069a8 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -42,11 +42,11 @@ func (h *DashboardHandler) Stats(c *gin.Context) { ctx := c.Request.Context() var stats DashboardStats - h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM artifacts").Scan(&stats.TotalArtifacts) - h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM repositories").Scan(&stats.TotalRepos) - h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups").Scan(&stats.TotalCrashGroups) - h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups WHERE status = 'open'").Scan(&stats.OpenCrashGroups) - h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM campaigns WHERE status = 'running'").Scan(&stats.ActiveCampaigns) + _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM artifacts").Scan(&stats.TotalArtifacts) + _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM repositories").Scan(&stats.TotalRepos) + _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups").Scan(&stats.TotalCrashGroups) + _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups WHERE status = 'open'").Scan(&stats.OpenCrashGroups) + _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM campaigns WHERE status = 'running'").Scan(&stats.ActiveCampaigns) // Artifact trend for the last 30 days. var trend []TrendPoint diff --git a/internal/handler/ingest.go b/internal/handler/ingest.go index 62e5b9e..7315747 100644 --- a/internal/handler/ingest.go +++ b/internal/handler/ingest.go @@ -106,7 +106,7 @@ func (h *IngestHandler) Create(c *gin.Context) { if result := fingerprint.Compute(req.StackTrace); result != nil { sig, isNew, err := models.GetOrCreateSignature(ctx, h.Pool, repo.ID, result.Fingerprint, stackTrace) if err == nil { - models.UpdateArtifactSignature(ctx, h.Pool, artifact.ID, sig.ID, result.Fingerprint) + _ = models.UpdateArtifactSignature(ctx, h.Pool, artifact.ID, sig.ID, result.Fingerprint) if isNew { title := req.Type + " crash in " + req.Repository @@ -115,7 +115,7 @@ func (h *IngestHandler) Create(c *gin.Context) { } group, groupErr := models.CreateCrashGroup(ctx, h.Pool, sig.ID, repo.ID, title) if groupErr == nil && h.ForgejoSync != nil { - h.ForgejoSync.CreateIssueForCrashGroup(ctx, group, req.StackTrace) + _ = h.ForgejoSync.CreateIssueForCrashGroup(ctx, group, req.StackTrace) } } } @@ -148,5 +148,5 @@ func (h *DownloadHandler) Download(c *gin.Context) { c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", artifact.BlobKey)) c.Header("Content-Type", "application/octet-stream") - io.Copy(c.Writer, reader) + _, _ = io.Copy(c.Writer, reader) } diff --git a/internal/handler/regression.go b/internal/handler/regression.go index 3b93401..ed121b8 100644 --- a/internal/handler/regression.go +++ b/internal/handler/regression.go @@ -53,7 +53,7 @@ func (h *RegressionHandler) Check(c *gin.Context) { description = fmt.Sprintf("%d new crash signature(s) detected", len(result.New)) } - h.ForgejoSync.Client.CreateCommitStatus(ctx, repo.Owner, repo.Name, req.HeadSHA, forgejo.CommitStatus{ + _ = h.ForgejoSync.Client.CreateCommitStatus(ctx, repo.Owner, repo.Name, req.HeadSHA, forgejo.CommitStatus{ State: state, Description: description, Context: "cairn/regression", diff --git a/internal/handler/webhooks.go b/internal/handler/webhooks.go index 4d57245..5477628 100644 --- a/internal/handler/webhooks.go +++ b/internal/handler/webhooks.go @@ -1,10 +1,11 @@ package handler import ( - "log" "net/http" "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "github.com/mattnite/cairn/internal/forgejo" ) @@ -16,7 +17,7 @@ type WebhookHandler struct { func (h *WebhookHandler) Handle(c *gin.Context) { event, eventType, err := forgejo.VerifyAndParse(c.Request, h.Secret) if err != nil { - log.Printf("Webhook error: %v", err) + log.Warn().Err(err).Msg("Webhook verification failed") c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } @@ -26,7 +27,7 @@ func (h *WebhookHandler) Handle(c *gin.Context) { switch eventType { case "issues": if err := h.Sync.HandleIssueEvent(ctx, event); err != nil { - log.Printf("Issue event error: %v", err) + log.Error().Err(err).Msg("Issue event error") } case "push": h.Sync.HandlePushEvent(ctx, event) diff --git a/internal/models/models.go b/internal/models/models.go index 32c933c..99fee0e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -6,12 +6,12 @@ import ( ) type Repository struct { - ID string `json:"id"` - Name string `json:"name"` - Owner string `json:"owner"` - ForgejoURL string `json:"forgejo_url,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + ForgejoURL string `json:"forgejo_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type Commit struct { diff --git a/internal/regression/regression.go b/internal/regression/regression.go index fd90418..363c166 100644 --- a/internal/regression/regression.go +++ b/internal/regression/regression.go @@ -9,13 +9,13 @@ import ( // Result holds the regression comparison between two commits. type Result struct { - BaseSHA string `json:"base_sha"` - HeadSHA string `json:"head_sha"` - RepoName string `json:"repo_name"` - New []string `json:"new"` // Fingerprints in head but not base. - Fixed []string `json:"fixed"` // Fingerprints in base but not head. - Recurring []string `json:"recurring"` // Fingerprints in both. - IsRegression bool `json:"is_regression"` + BaseSHA string `json:"base_sha"` + HeadSHA string `json:"head_sha"` + RepoName string `json:"repo_name"` + New []string `json:"new"` // Fingerprints in head but not base. + Fixed []string `json:"fixed"` // Fingerprints in base but not head. + Recurring []string `json:"recurring"` // Fingerprints in both. + IsRegression bool `json:"is_regression"` } // Compare computes the set difference of crash fingerprints between a base and head commit. diff --git a/internal/web/middleware.go b/internal/web/middleware.go index d4e2b37..0487a1c 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -1,16 +1,45 @@ package web import ( - "log" + "net" + "net/http" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" ) func LoggingMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() - log.Printf("%s %s %d %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(start)) + log.Info(). + Str("method", c.Request.Method). + Str("path", c.Request.URL.Path). + Int("status", c.Writer.Status()). + Dur("latency", time.Since(start)). + Msg("Request") + } +} + +func RecoveryMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + // Don't log broken pipe errors — client disconnected. + if err, ok := r.(error); ok { + if ne, ok := err.(*net.OpError); ok && ne.Op == "write" { + if strings.Contains(ne.Err.Error(), "broken pipe") || strings.Contains(ne.Err.Error(), "connection reset") { + c.Abort() + return + } + } + } + log.Error().Interface("panic", r).Str("path", c.Request.URL.Path).Msg("Recovery from panic") + c.AbortWithStatus(http.StatusInternalServerError) + } + }() + c.Next() } } diff --git a/internal/web/routes.go b/internal/web/routes.go index 1360814..f6150a7 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -13,10 +13,10 @@ import ( ) type RouterConfig struct { - Pool *pgxpool.Pool - Store blob.Store - ForgejoClient *forgejo.Client - WebhookSecret string + Pool *pgxpool.Pool + Store blob.Store + ForgejoClient *forgejo.Client + WebhookSecret string } func NewRouter(cfg RouterConfig) (*gin.Engine, error) { @@ -38,7 +38,9 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) { dashboardAPI := &handler.DashboardHandler{Pool: cfg.Pool} webhookH := &handler.WebhookHandler{Sync: forgejoSync, Secret: cfg.WebhookSecret} - r := gin.Default() + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(LoggingMiddleware(), RecoveryMiddleware()) // Static files staticFS, err := fs.Sub(assets.Assets, "static") diff --git a/internal/web/web.go b/internal/web/web.go index b8ddfc6..b9357aa 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -38,8 +38,8 @@ func (h *PageHandler) Index(c *gin.Context) { } var totalCG, openCG int - h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups").Scan(&totalCG) - h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups WHERE status = 'open'").Scan(&openCG) + _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups").Scan(&totalCG) + _ = h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups WHERE status = 'open'").Scan(&openCG) // Top crashers type topCrasher struct { @@ -80,7 +80,7 @@ func (h *PageHandler) Index(c *gin.Context) { }, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "index", data) + _ = h.Templates.Render(c.Writer, "index", data) } func (h *PageHandler) Artifacts(c *gin.Context) { @@ -111,7 +111,7 @@ func (h *PageHandler) Artifacts(c *gin.Context) { }, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "artifacts", data) + _ = h.Templates.Render(c.Writer, "artifacts", data) } func (h *PageHandler) ArtifactDetail(c *gin.Context) { @@ -128,7 +128,7 @@ func (h *PageHandler) ArtifactDetail(c *gin.Context) { Content: artifact, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "artifact_detail", data) + _ = h.Templates.Render(c.Writer, "artifact_detail", data) } func (h *PageHandler) Repos(c *gin.Context) { @@ -145,7 +145,7 @@ func (h *PageHandler) Repos(c *gin.Context) { }, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "repos", data) + _ = h.Templates.Render(c.Writer, "repos", data) } func (h *PageHandler) CrashGroups(c *gin.Context) { @@ -175,7 +175,7 @@ func (h *PageHandler) CrashGroups(c *gin.Context) { }, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "crashgroups", data) + _ = h.Templates.Render(c.Writer, "crashgroups", data) } func (h *PageHandler) CrashGroupDetail(c *gin.Context) { @@ -201,7 +201,7 @@ func (h *PageHandler) CrashGroupDetail(c *gin.Context) { }, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "crashgroup_detail", data) + _ = h.Templates.Render(c.Writer, "crashgroup_detail", data) } func (h *PageHandler) Search(c *gin.Context) { @@ -222,7 +222,7 @@ func (h *PageHandler) Search(c *gin.Context) { }, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "search", data) + _ = h.Templates.Render(c.Writer, "search", data) } func (h *PageHandler) Regression(c *gin.Context) { @@ -252,7 +252,7 @@ func (h *PageHandler) Regression(c *gin.Context) { Content: content, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "regression", data) + _ = h.Templates.Render(c.Writer, "regression", data) } func (h *PageHandler) Campaigns(c *gin.Context) { @@ -276,7 +276,7 @@ func (h *PageHandler) Campaigns(c *gin.Context) { }, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "campaigns", data) + _ = h.Templates.Render(c.Writer, "campaigns", data) } func (h *PageHandler) CampaignDetail(c *gin.Context) { @@ -301,5 +301,5 @@ func (h *PageHandler) CampaignDetail(c *gin.Context) { }, } c.Header("Content-Type", "text/html; charset=utf-8") - h.Templates.Render(c.Writer, "campaign_detail", data) + _ = h.Templates.Render(c.Writer, "campaign_detail", data) } diff --git a/web/static/css/cairn.css b/web/static/css/cairn.css index 8a76387..9252f6b 100644 --- a/web/static/css/cairn.css +++ b/web/static/css/cairn.css @@ -1,280 +1,531 @@ +@import url('https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;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; + --bg: #080508; + --bg-surface: #100b10; + --bg-hover: #1a1018; + --border: #301828; + --border-glow: #501838; + --text: #c8b8be; + --text-muted: #6a545e; + --accent: #e02848; + --accent-hover: #ff3860; + --accent-glow: rgba(224, 40, 72, 0.5); + --accent-glow-soft: rgba(224, 40, 72, 0.2); + --danger: #ff2233; + --warning: #b8862e; + --success: #3d7a4a; + --bone: #b8a890; + --bone-dim: #685848; + --ember: #cc3040; --sidebar-width: 220px; - --radius: 6px; - --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + --radius: 2px; + --font-heading: 'Cinzel Decorative', 'Georgia', serif; + --font-mono: 'Fira Code', 'Courier New', monospace; } * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 14px; background: var(--bg); + background-image: + repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(100, 20, 40, 0.015) 2px, + rgba(100, 20, 40, 0.015) 4px + ); color: var(--text); display: flex; min-height: 100vh; } -/* Sidebar */ +/* ======= SIDEBAR ======= */ .sidebar { width: var(--sidebar-width); background: var(--bg-surface); - border-right: 1px solid var(--border); - padding: 1.5rem 0; + border-right: 2px ridge #3a1828; + padding: 0; position: fixed; top: 0; bottom: 0; overflow-y: auto; + display: flex; + flex-direction: column; } -.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: 1.25rem 1rem 1rem; + border-bottom: 2px groove #3a1828; + text-align: center; +} +.logo { + font-family: var(--font-heading); + font-size: 1.4rem; + font-weight: 700; + color: var(--accent); + text-shadow: + 0 0 10px var(--accent-glow), + 0 0 30px rgba(224, 40, 72, 0.3), + 0 0 60px rgba(224, 40, 72, 0.1); + letter-spacing: 0.2em; + text-transform: uppercase; + animation: logo-pulse 4s ease-in-out infinite; +} +@keyframes logo-pulse { + 0%, 100% { text-shadow: 0 0 10px var(--accent-glow), 0 0 30px rgba(224, 40, 72, 0.3); } + 50% { text-shadow: 0 0 15px var(--accent-glow), 0 0 40px rgba(224, 40, 72, 0.45), 0 0 70px rgba(224, 40, 72, 0.15); } +} +.logo-sigil { display: block; - padding: 0.625rem 1.25rem; + font-size: 1.8rem; + line-height: 1; + margin-bottom: 0.35rem; + color: var(--accent); + filter: drop-shadow(0 0 6px var(--accent-glow)); + letter-spacing: 0.3em; +} + +.nav-links { list-style: none; padding: 0.5rem 0; flex: 1; } +.nav-links li { position: relative; } +.nav-links a { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 1rem; color: var(--text-muted); text-decoration: none; - font-size: 0.875rem; + font-size: 0.82rem; + font-family: 'Georgia', serif; transition: all 0.15s; + border-left: 3px solid transparent; } .nav-links a:hover { - color: var(--text); - background: var(--bg-hover); + color: var(--accent-hover); + background: linear-gradient(90deg, rgba(224, 40, 72, 0.1) 0%, transparent 100%); + border-left-color: var(--accent); + text-shadow: 0 0 10px var(--accent-glow); +} +.nav-icon { + font-size: 0.95rem; + width: 1.2rem; + text-align: center; + color: var(--bone-dim); +} +.nav-links a:hover .nav-icon { + color: var(--accent); + filter: drop-shadow(0 0 4px var(--accent-glow)); } -/* Main content */ +.sidebar-divider { + border: none; + border-top: 1px dashed var(--border); + margin: 0.4rem 0.8rem; + opacity: 0.6; +} + +.sidebar-footer { + padding: 0.8rem 1rem; + border-top: 2px groove #3a1828; + font-size: 0.65rem; + color: var(--bone-dim); + font-family: var(--font-heading); + letter-spacing: 0.12em; + text-align: center; + text-transform: lowercase; +} + +/* ======= MAIN CONTENT ======= */ .content { margin-left: var(--sidebar-width); flex: 1; - padding: 2rem; + padding: 1.5rem 2rem; max-width: 1200px; } .page-header { - margin-bottom: 1.5rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border); + margin-bottom: 1.25rem; + padding-bottom: 0.75rem; + border-bottom: 2px groove #3a1828; +} +.page-header h2 { + font-family: var(--font-heading); + font-size: 1.2rem; + font-weight: 700; + color: var(--bone); + letter-spacing: 0.08em; + text-shadow: 0 1px 3px rgba(0,0,0,0.5); } -.page-header h2 { font-size: 1.5rem; font-weight: 600; } -/* Stats */ +/* ======= STATS ======= */ .stats-row { display: flex; - gap: 1rem; - margin-bottom: 2rem; + gap: 0.75rem; + margin-bottom: 1.5rem; } .stat-card { background: var(--bg-surface); - border: 1px solid var(--border); + border: 2px ridge #3a1828; border-radius: var(--radius); - padding: 1.25rem 1.5rem; + padding: 1rem 1.25rem; display: flex; flex-direction: column; - min-width: 160px; + min-width: 150px; + position: relative; + transition: border-color 0.3s, box-shadow 0.3s; +} +.stat-card:hover { + border-color: var(--border-glow); + box-shadow: + 0 0 12px var(--accent-glow-soft), + inset 0 0 20px rgba(224, 40, 72, 0.06); +} +.stat-value { + font-family: var(--font-heading); + font-size: 1.75rem; + font-weight: 700; + color: var(--accent); + text-shadow: 0 0 12px var(--accent-glow), 0 0 25px rgba(224, 40, 72, 0.2); +} +.stat-label { + font-size: 0.7rem; + color: var(--text-muted); + margin-top: 0.2rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-family: 'Georgia', serif; } -.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 */ +/* ======= TABLES ======= */ .table { width: 100%; border-collapse: collapse; - font-size: 0.875rem; + font-size: 0.82rem; + border: 1px solid var(--border); } .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; + padding: 0.6rem 0.75rem; + border-bottom: 2px solid var(--border-glow); + border-right: 1px solid rgba(48, 24, 40, 0.3); + background: rgba(16, 8, 14, 0.6); + color: var(--bone-dim); + font-family: var(--font-heading); + font-weight: 700; + font-size: 0.65rem; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.12em; } +.table th:last-child { border-right: none; } .table td { - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border); + padding: 0.55rem 0.75rem; + border-bottom: 1px solid rgba(48, 24, 40, 0.4); + border-right: 1px solid rgba(48, 24, 40, 0.15); +} +.table td:last-child { border-right: none; } +.table tr:hover td { + background: var(--bg-hover); + box-shadow: inset 3px 0 0 var(--ember); } -.table tr:hover td { background: var(--bg-hover); } -/* Badges */ +/* ======= BADGES ======= */ .badge { display: inline-block; - padding: 0.2rem 0.5rem; - border-radius: 3px; - font-size: 0.75rem; + padding: 0.15rem 0.45rem; + border-radius: var(--radius); + font-size: 0.7rem; font-weight: 600; font-family: var(--font-mono); + border: 1px solid; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.badge-coredump { + background: rgba(255, 34, 51, 0.1); + color: var(--danger); + border-color: rgba(255, 34, 51, 0.3); + text-shadow: 0 0 6px rgba(255, 34, 51, 0.4); +} +.badge-fuzz { + background: rgba(61, 122, 74, 0.1); + color: var(--success); + border-color: rgba(61, 122, 74, 0.3); +} +.badge-sanitizer { + background: rgba(184, 134, 46, 0.1); + color: var(--warning); + border-color: rgba(184, 134, 46, 0.3); +} +.badge-simulation { + background: rgba(224, 40, 72, 0.08); + color: var(--accent-hover); + border-color: rgba(224, 40, 72, 0.25); } -.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 */ +/* ======= BUTTONS ======= */ .btn { display: inline-block; - padding: 0.5rem 1rem; + padding: 0.4rem 0.9rem; background: var(--bg-surface); - color: var(--text); - border: 1px solid var(--border); + color: var(--bone); + border: 2px ridge #3a1828; border-radius: var(--radius); - font-size: 0.875rem; + font-size: 0.8rem; text-decoration: none; cursor: pointer; transition: all 0.15s; + font-family: 'Georgia', serif; + letter-spacing: 0.03em; } -.btn:hover { background: var(--bg-hover); border-color: var(--accent); } -.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.8rem; } +.btn:hover { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--accent-hover); + box-shadow: 0 0 10px var(--accent-glow-soft); + text-shadow: 0 0 6px var(--accent-glow); +} +.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.75rem; } -/* Code */ +/* ======= CODE ======= */ code { font-family: var(--font-mono); - font-size: 0.85em; - background: var(--bg-hover); - padding: 0.15rem 0.35rem; - border-radius: 3px; + font-size: 0.82em; + background: rgba(16, 8, 14, 0.8); + padding: 0.1rem 0.3rem; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--accent-hover); } .code-block { - background: var(--bg-surface); - border: 1px solid var(--border); + background: #0a060a; + border: 2px ridge #3a1828; + border-left: 4px solid var(--ember); border-radius: var(--radius); - padding: 1rem; + padding: 0.8rem; overflow-x: auto; font-family: var(--font-mono); - font-size: 0.8rem; - line-height: 1.6; + font-size: 0.78rem; + line-height: 1.5; white-space: pre-wrap; word-break: break-all; + box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.6); } -/* Detail pages */ +/* ======= DETAIL PAGES ======= */ .detail-header { display: flex; align-items: center; - gap: 1rem; - margin-bottom: 1.5rem; + gap: 0.75rem; + margin-bottom: 1.25rem; } .detail-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 1rem; - margin-bottom: 2rem; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.5rem; } .detail-item { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); - padding: 1rem; + padding: 0.75rem; + transition: border-color 0.2s; +} +.detail-item:hover { + border-color: var(--border-glow); + box-shadow: 0 0 8px rgba(224, 40, 72, 0.08); } .detail-item label { display: block; - font-size: 0.75rem; - color: var(--text-muted); + font-family: var(--font-heading); + font-size: 0.6rem; + color: var(--bone-dim); text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 0.375rem; + letter-spacing: 0.1em; + margin-bottom: 0.3rem; } -.detail-actions { margin-top: 2rem; } +.detail-actions { margin-top: 1.5rem; } -/* Sections */ -.section { margin-bottom: 2rem; } -.section h3 { margin-bottom: 0.75rem; font-size: 1rem; } +/* ======= SECTIONS ======= */ +.section { margin-bottom: 1.5rem; } +.section h3 { + margin-bottom: 0.6rem; + font-family: var(--font-heading); + font-size: 0.95rem; + color: var(--bone); + letter-spacing: 0.06em; + text-shadow: 0 1px 3px rgba(0,0,0,0.4); +} -/* Utilities */ +/* ======= UTILITIES ======= */ .empty-state { color: var(--text-muted); - padding: 3rem; + padding: 2.5rem; text-align: center; - font-size: 0.9rem; + font-size: 0.85rem; + font-style: italic; + border: 1px dashed var(--border); } .toolbar { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; + margin-bottom: 0.75rem; } -.result-count { color: var(--text-muted); font-size: 0.875rem; } +.result-count { color: var(--text-muted); font-size: 0.82rem; } .pagination { display: flex; - gap: 0.5rem; + gap: 0.4rem; justify-content: center; - margin-top: 1.5rem; + margin-top: 1.25rem; } -/* Status badges */ -.badge-status-open { background: #3b2a2a; color: var(--danger); } -.badge-status-resolved { background: #2a3b2a; color: var(--success); } +/* ======= STATUS BADGES ======= */ +.badge-status-open { + background: rgba(255, 34, 51, 0.1); + color: var(--danger); + border-color: rgba(255, 34, 51, 0.35); + text-shadow: 0 0 6px rgba(255, 34, 51, 0.5); +} +.badge-status-resolved { + background: rgba(61, 122, 74, 0.1); + color: var(--success); + border-color: rgba(61, 122, 74, 0.3); +} -/* Campaign badges */ -.badge-campaign-running { background: #2a2a3b; color: var(--accent); } -.badge-campaign-finished { background: #2a3b2a; color: var(--success); } +/* ======= CAMPAIGN BADGES ======= */ +.badge-campaign-running { + background: rgba(224, 40, 72, 0.08); + color: var(--accent-hover); + border-color: rgba(224, 40, 72, 0.25); + text-shadow: 0 0 6px var(--accent-glow); +} +.badge-campaign-finished { + background: rgba(61, 122, 74, 0.1); + color: var(--success); + border-color: rgba(61, 122, 74, 0.3); +} -/* Search */ +/* ======= SEARCH ======= */ .search-form { display: flex; - gap: 0.75rem; - margin-bottom: 1.5rem; + gap: 0.6rem; + margin-bottom: 1.25rem; } .search-input { flex: 1; - padding: 0.625rem 1rem; - background: var(--bg-surface); - border: 1px solid var(--border); + padding: 0.5rem 0.8rem; + background: #0a060a; + border: 2px ridge #3a1828; border-radius: var(--radius); color: var(--text); - font-size: 0.9rem; + font-size: 0.85rem; + font-family: var(--font-mono); + transition: all 0.15s; } .search-input:focus { outline: none; border-color: var(--accent); + box-shadow: 0 0 10px var(--accent-glow-soft); } -/* Crash group detail */ +/* ======= CRASH GROUP DETAIL ======= */ .crashgroup-title { - font-size: 1.25rem; - margin-bottom: 1.5rem; + font-family: var(--font-heading); + font-size: 1.1rem; + margin-bottom: 1.25rem; + color: var(--bone); } -/* Regression */ +/* ======= REGRESSION ======= */ .form-row { display: flex; - gap: 0.75rem; + gap: 0.6rem; 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 { display: flex; flex-direction: column; gap: 0.3rem; flex: 1; } +.form-group label { + font-family: var(--font-heading); + font-size: 0.6rem; + color: var(--bone-dim); + text-transform: uppercase; + letter-spacing: 0.1em; +} +.form-group input { + padding: 0.5rem 0.8rem; + background: #0a060a; + border: 2px ridge #3a1828; + border-radius: var(--radius); + color: var(--text); + font-size: 0.85rem; + font-family: var(--font-mono); + transition: all 0.15s; +} +.form-group input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 10px var(--accent-glow-soft); +} .regression-result { - border: 1px solid var(--border); + border: 2px ridge #3a1828; border-radius: var(--radius); - padding: 1.5rem; - margin-top: 1.5rem; + padding: 1.25rem; + margin-top: 1.25rem; } .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 15px rgba(255, 34, 51, 0.2); +} +.regression-verdict { + font-family: var(--font-heading); + font-size: 1rem; + margin-bottom: 0.75rem; + letter-spacing: 0.05em; +} +.regression-fail .regression-verdict { + color: var(--danger); + text-shadow: 0 0 12px rgba(255, 34, 51, 0.5); +} .regression-pass .regression-verdict { color: var(--success); } .fingerprint-list { list-style: none; padding: 0; } -.fingerprint-list li { padding: 0.375rem 0; } +.fingerprint-list li { + padding: 0.3rem 0; + font-family: var(--font-mono); + font-size: 0.78rem; +} + +/* ======= LINKS ======= */ +a { color: var(--accent); text-decoration: none; transition: all 0.15s; } +a:hover { + color: var(--accent-hover); + text-shadow: 0 0 8px var(--accent-glow); + text-decoration: underline; +} + +/* ======= SCROLLBAR ======= */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { + background: #2a1420; + border: 1px solid #3a1828; +} +::-webkit-scrollbar-thumb:hover { background: var(--border-glow); } + +/* ======= SELECTION ======= */ +::selection { + background: rgba(224, 40, 72, 0.35); + color: #fff; +} diff --git a/web/templates/layout.html b/web/templates/layout.html index bab6275..bd8ab71 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -9,17 +9,20 @@