Merge pull request 'ci' (#1) from ci into main

Reviewed-on: https://git.ts.mattnite.net/mattnite/cairn/pulls/1
This commit is contained in:
Matthew Knight 2026-03-03 04:00:36 +00:00
commit 46814055d4
29 changed files with 1497 additions and 217 deletions

View File

@ -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 }}

View File

@ -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 .

4
.gitignore vendored
View File

@ -1,5 +1,5 @@
cairn-server
cairn
/cairn-server
/cairn
*.exe
*.test
*.out

6
.golangci.yml Normal file
View File

@ -0,0 +1,6 @@
version: "2"
linters:
exclusions:
presets:
- std-error-handling

220
README.md Normal file
View File

@ -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": ["<fingerprints appearing in head but not base>"],
"fixed": ["<fingerprints in base but not head>"],
"recurring": ["<fingerprints in both>"]
}
```
### Campaigns
```sh
# Start a campaign
cairn campaign start -repo myproject -owner myorg -name "nightly-fuzz" -type fuzz
# Finish a campaign
cairn campaign finish -id <campaign-id>
```
### Download an artifact
```sh
cairn download -id <artifact-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<int>` → `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.

91
cmd/cairn-server/main.go Normal file
View File

@ -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")
}
}

426
cmd/cairn/main.go Normal file
View File

@ -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 <start|finish>\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 <command> [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
}

4
go.mod
View File

@ -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

11
go.sum
View File

@ -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=

View File

@ -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", ""),

View File

@ -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

View File

@ -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.

View File

@ -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`,
)

View File

@ -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+))?`,
)

View File

@ -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+\(([^)]*)\)`,
)

View File

@ -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
}

View File

@ -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")
}

View File

@ -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 {

View File

@ -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

View File

@ -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)
}

View File

@ -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",

View File

@ -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)

View File

@ -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 {

View File

@ -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.

View File

@ -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()
}
}

View File

@ -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")

View File

@ -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)
}

View File

@ -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;
}

View File

@ -9,17 +9,20 @@
<body>
<nav class="sidebar">
<div class="sidebar-header">
<span class="logo-sigil">&#5855;&#5868;&#5833;</span>
<h1 class="logo">Cairn</h1>
</div>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/artifacts">Artifacts</a></li>
<li><a href="/crashgroups">Crash Groups</a></li>
<li><a href="/campaigns">Campaigns</a></li>
<li><a href="/repos">Repositories</a></li>
<li><a href="/regression">Regression</a></li>
<li><a href="/search">Search</a></li>
<li><a href="/"><span class="nav-icon">&#5855;</span> Dashboard</a></li>
<li><a href="/artifacts"><span class="nav-icon">&#5833;</span> Artifacts</a></li>
<li><a href="/crashgroups"><span class="nav-icon">&#5854;</span> Crash Groups</a></li>
<li><a href="/campaigns"><span class="nav-icon">&#5839;</span> Campaigns</a></li>
<hr class="sidebar-divider">
<li><a href="/repos"><span class="nav-icon">&#5798;</span> Repositories</a></li>
<li><a href="/regression"><span class="nav-icon">&#5834;</span> Regression</a></li>
<li><a href="/search"><span class="nav-icon">&#5800;</span> Search</a></li>
</ul>
<div class="sidebar-footer">the dead remember</div>
</nav>
<main class="content">
<header class="page-header">