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:
commit
46814055d4
|
|
@ -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 }}
|
||||
|
|
@ -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 .
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
cairn-server
|
||||
cairn
|
||||
/cairn-server
|
||||
/cairn
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
version: "2"
|
||||
|
||||
linters:
|
||||
exclusions:
|
||||
presets:
|
||||
- std-error-handling
|
||||
|
|
@ -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.
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
4
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
|
||||
|
|
|
|||
11
go.sum
11
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=
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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+))?`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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+\(([^)]*)\)`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,20 @@
|
|||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="logo-sigil">ᛟ᛬ᛉ</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">ᛟ</span> Dashboard</a></li>
|
||||
<li><a href="/artifacts"><span class="nav-icon">ᛉ</span> Artifacts</a></li>
|
||||
<li><a href="/crashgroups"><span class="nav-icon">ᛞ</span> Crash Groups</a></li>
|
||||
<li><a href="/campaigns"><span class="nav-icon">ᛏ</span> Campaigns</a></li>
|
||||
<hr class="sidebar-divider">
|
||||
<li><a href="/repos"><span class="nav-icon">ᚦ</span> Repositories</a></li>
|
||||
<li><a href="/regression"><span class="nav-icon">ᛊ</span> Regression</a></li>
|
||||
<li><a href="/search"><span class="nav-icon">ᚨ</span> Search</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">the dead remember</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
<header class="page-header">
|
||||
|
|
|
|||
Loading…
Reference in New Issue