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-server
|
||||||
cairn
|
/cairn
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.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
|
module github.com/mattnite/cairn
|
||||||
|
|
||||||
go 1.25.3
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
github.com/minio/minio-go/v7 v7.0.98
|
github.com/minio/minio-go/v7 v7.0.98
|
||||||
|
github.com/rs/zerolog v1.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -31,6 +32,7 @@ require (
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // 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/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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=
|
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/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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
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/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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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/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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//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 {
|
if _, err := tx.Exec(ctx, string(sql)); err != nil {
|
||||||
tx.Rollback(ctx)
|
_ = tx.Rollback(ctx)
|
||||||
return fmt.Errorf("executing migration %s: %w", version, err)
|
return fmt.Errorf("executing migration %s: %w", version, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
|
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)
|
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)
|
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
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ASan/MSan/TSan/UBSan frame patterns:
|
// ASan/MSan/TSan/UBSan frame patterns:
|
||||||
|
//
|
||||||
// #0 0x55a3b4 in function_name /path/to/file.c:42:13
|
// #0 0x55a3b4 in function_name /path/to/file.c:42:13
|
||||||
// #0 0x55a3b4 in function_name (/path/to/binary+0x1234)
|
// #0 0x55a3b4 in function_name (/path/to/binary+0x1234)
|
||||||
// #1 0x55a3b4 (/path/to/binary+0x1234)
|
// #1 0x55a3b4 (/path/to/binary+0x1234)
|
||||||
|
|
@ -15,6 +16,7 @@ var asanFrameRe = regexp.MustCompile(
|
||||||
)
|
)
|
||||||
|
|
||||||
// ASan error header line, e.g.:
|
// ASan error header line, e.g.:
|
||||||
|
//
|
||||||
// ==12345==ERROR: AddressSanitizer: heap-buffer-overflow
|
// ==12345==ERROR: AddressSanitizer: heap-buffer-overflow
|
||||||
var asanHeaderRe = regexp.MustCompile(
|
var asanHeaderRe = regexp.MustCompile(
|
||||||
`==\d+==ERROR:\s+(Address|Memory|Thread|Undefined)Sanitizer`,
|
`==\d+==ERROR:\s+(Address|Memory|Thread|Undefined)Sanitizer`,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// GDB backtrace frame patterns:
|
// GDB backtrace frame patterns:
|
||||||
|
//
|
||||||
// #0 function_name (args) at /path/to/file.c:42
|
// #0 function_name (args) at /path/to/file.c:42
|
||||||
// #0 0x00007fff in function_name () from /lib/libfoo.so
|
// #0 0x00007fff in function_name () from /lib/libfoo.so
|
||||||
// #0 0x00007fff in ?? ()
|
// #0 0x00007fff in ?? ()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Zig panic/stack trace patterns:
|
// Zig panic/stack trace patterns:
|
||||||
|
//
|
||||||
// /path/to/file.zig:42:13: 0x1234 in function_name (module)
|
// /path/to/file.zig:42:13: 0x1234 in function_name (module)
|
||||||
// ???:?:?: 0x1234 in ??? (???)
|
// ???:?:?: 0x1234 in ??? (???)
|
||||||
var zigFrameRe = regexp.MustCompile(
|
var zigFrameRe = regexp.MustCompile(
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ func (c *Client) do(ctx context.Context, method, path string, body any) (*http.R
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
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))
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ package forgejo
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/mattnite/cairn/internal/models"
|
"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 == "" {
|
if event.Repo == nil || event.After == "" {
|
||||||
return
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,11 @@ func (h *DashboardHandler) Stats(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
var stats DashboardStats
|
var stats DashboardStats
|
||||||
|
|
||||||
h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM artifacts").Scan(&stats.TotalArtifacts)
|
_ = 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 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").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 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 campaigns WHERE status = 'running'").Scan(&stats.ActiveCampaigns)
|
||||||
|
|
||||||
// Artifact trend for the last 30 days.
|
// Artifact trend for the last 30 days.
|
||||||
var trend []TrendPoint
|
var trend []TrendPoint
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ func (h *IngestHandler) Create(c *gin.Context) {
|
||||||
if result := fingerprint.Compute(req.StackTrace); result != nil {
|
if result := fingerprint.Compute(req.StackTrace); result != nil {
|
||||||
sig, isNew, err := models.GetOrCreateSignature(ctx, h.Pool, repo.ID, result.Fingerprint, stackTrace)
|
sig, isNew, err := models.GetOrCreateSignature(ctx, h.Pool, repo.ID, result.Fingerprint, stackTrace)
|
||||||
if err == nil {
|
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 {
|
if isNew {
|
||||||
title := req.Type + " crash in " + req.Repository
|
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)
|
group, groupErr := models.CreateCrashGroup(ctx, h.Pool, sig.ID, repo.ID, title)
|
||||||
if groupErr == nil && h.ForgejoSync != nil {
|
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-Disposition", fmt.Sprintf("attachment; filename=%q", artifact.BlobKey))
|
||||||
c.Header("Content-Type", "application/octet-stream")
|
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))
|
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,
|
State: state,
|
||||||
Description: description,
|
Description: description,
|
||||||
Context: "cairn/regression",
|
Context: "cairn/regression",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/mattnite/cairn/internal/forgejo"
|
"github.com/mattnite/cairn/internal/forgejo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,7 +17,7 @@ type WebhookHandler struct {
|
||||||
func (h *WebhookHandler) Handle(c *gin.Context) {
|
func (h *WebhookHandler) Handle(c *gin.Context) {
|
||||||
event, eventType, err := forgejo.VerifyAndParse(c.Request, h.Secret)
|
event, eventType, err := forgejo.VerifyAndParse(c.Request, h.Secret)
|
||||||
if err != nil {
|
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()})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +27,7 @@ func (h *WebhookHandler) Handle(c *gin.Context) {
|
||||||
switch eventType {
|
switch eventType {
|
||||||
case "issues":
|
case "issues":
|
||||||
if err := h.Sync.HandleIssueEvent(ctx, event); err != nil {
|
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":
|
case "push":
|
||||||
h.Sync.HandlePushEvent(ctx, event)
|
h.Sync.HandlePushEvent(ctx, event)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,45 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoggingMiddleware() gin.HandlerFunc {
|
func LoggingMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
c.Next()
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) {
|
||||||
dashboardAPI := &handler.DashboardHandler{Pool: cfg.Pool}
|
dashboardAPI := &handler.DashboardHandler{Pool: cfg.Pool}
|
||||||
webhookH := &handler.WebhookHandler{Sync: forgejoSync, Secret: cfg.WebhookSecret}
|
webhookH := &handler.WebhookHandler{Sync: forgejoSync, Secret: cfg.WebhookSecret}
|
||||||
|
|
||||||
r := gin.Default()
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(LoggingMiddleware(), RecoveryMiddleware())
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
staticFS, err := fs.Sub(assets.Assets, "static")
|
staticFS, err := fs.Sub(assets.Assets, "static")
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ func (h *PageHandler) Index(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCG, openCG int
|
var totalCG, openCG int
|
||||||
h.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM crash_groups").Scan(&totalCG)
|
_ = 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 WHERE status = 'open'").Scan(&openCG)
|
||||||
|
|
||||||
// Top crashers
|
// Top crashers
|
||||||
type topCrasher struct {
|
type topCrasher struct {
|
||||||
|
|
@ -80,7 +80,7 @@ func (h *PageHandler) Index(c *gin.Context) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
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) {
|
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")
|
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) {
|
func (h *PageHandler) ArtifactDetail(c *gin.Context) {
|
||||||
|
|
@ -128,7 +128,7 @@ func (h *PageHandler) ArtifactDetail(c *gin.Context) {
|
||||||
Content: artifact,
|
Content: artifact,
|
||||||
}
|
}
|
||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
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) {
|
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")
|
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) {
|
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")
|
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) {
|
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")
|
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) {
|
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")
|
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) {
|
func (h *PageHandler) Regression(c *gin.Context) {
|
||||||
|
|
@ -252,7 +252,7 @@ func (h *PageHandler) Regression(c *gin.Context) {
|
||||||
Content: content,
|
Content: content,
|
||||||
}
|
}
|
||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
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) {
|
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")
|
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) {
|
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")
|
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 {
|
:root {
|
||||||
--bg: #0f1117;
|
--bg: #080508;
|
||||||
--bg-surface: #1a1d27;
|
--bg-surface: #100b10;
|
||||||
--bg-hover: #242836;
|
--bg-hover: #1a1018;
|
||||||
--border: #2a2e3d;
|
--border: #301828;
|
||||||
--text: #e1e4ed;
|
--border-glow: #501838;
|
||||||
--text-muted: #8b90a0;
|
--text: #c8b8be;
|
||||||
--accent: #6c8cff;
|
--text-muted: #6a545e;
|
||||||
--accent-hover: #8ba4ff;
|
--accent: #e02848;
|
||||||
--danger: #ff6b6b;
|
--accent-hover: #ff3860;
|
||||||
--warning: #ffd666;
|
--accent-glow: rgba(224, 40, 72, 0.5);
|
||||||
--success: #69db7c;
|
--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;
|
--sidebar-width: 220px;
|
||||||
--radius: 6px;
|
--radius: 2px;
|
||||||
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
--font-heading: 'Cinzel Decorative', 'Georgia', serif;
|
||||||
|
--font-mono: 'Fira Code', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: 14px;
|
||||||
background: var(--bg);
|
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);
|
color: var(--text);
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar */
|
/* ======= SIDEBAR ======= */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 2px ridge #3a1828;
|
||||||
padding: 1.5rem 0;
|
padding: 0;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header { padding: 0 1.25rem 1.5rem; }
|
.sidebar-header {
|
||||||
.logo { font-size: 1.25rem; font-weight: 700; color: var(--accent); }
|
padding: 1.25rem 1rem 1rem;
|
||||||
|
border-bottom: 2px groove #3a1828;
|
||||||
.nav-links { list-style: none; }
|
text-align: center;
|
||||||
.nav-links a {
|
}
|
||||||
|
.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;
|
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);
|
color: var(--text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.875rem;
|
font-size: 0.82rem;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
}
|
}
|
||||||
.nav-links a:hover {
|
.nav-links a:hover {
|
||||||
color: var(--text);
|
color: var(--accent-hover);
|
||||||
background: var(--bg-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 {
|
.content {
|
||||||
margin-left: var(--sidebar-width);
|
margin-left: var(--sidebar-width);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 1.5rem 2rem;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.25rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.stats-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 2px ridge #3a1828;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1rem 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.875rem;
|
font-size: 0.82rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.table th {
|
.table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.6rem 0.75rem;
|
||||||
border-bottom: 2px solid var(--border);
|
border-bottom: 2px solid var(--border-glow);
|
||||||
color: var(--text-muted);
|
border-right: 1px solid rgba(48, 24, 40, 0.3);
|
||||||
font-weight: 500;
|
background: rgba(16, 8, 14, 0.6);
|
||||||
font-size: 0.75rem;
|
color: var(--bone-dim);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.65rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.12em;
|
||||||
}
|
}
|
||||||
|
.table th:last-child { border-right: none; }
|
||||||
.table td {
|
.table td {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.55rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.15rem 0.45rem;
|
||||||
border-radius: 3px;
|
border-radius: var(--radius);
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-mono);
|
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 {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.4rem 0.9rem;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
color: var(--text);
|
color: var(--bone);
|
||||||
border: 1px solid var(--border);
|
border: 2px ridge #3a1828;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
.btn:hover { background: var(--bg-hover); border-color: var(--accent); }
|
.btn:hover {
|
||||||
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.8rem; }
|
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 {
|
code {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85em;
|
font-size: 0.82em;
|
||||||
background: var(--bg-hover);
|
background: rgba(16, 8, 14, 0.8);
|
||||||
padding: 0.15rem 0.35rem;
|
padding: 0.1rem 0.3rem;
|
||||||
border-radius: 3px;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
background: var(--bg-surface);
|
background: #0a060a;
|
||||||
border: 1px solid var(--border);
|
border: 2px ridge #3a1828;
|
||||||
|
border-left: 4px solid var(--ember);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1rem;
|
padding: 0.8rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.6;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail pages */
|
/* ======= DETAIL PAGES ======= */
|
||||||
.detail-header {
|
.detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item {
|
.detail-item {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
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 {
|
.detail-item label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.75rem;
|
font-family: var(--font-heading);
|
||||||
color: var(--text-muted);
|
font-size: 0.6rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.1em;
|
||||||
margin-bottom: 0.375rem;
|
margin-bottom: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-actions { margin-top: 2rem; }
|
.detail-actions { margin-top: 1.5rem; }
|
||||||
|
|
||||||
/* Sections */
|
/* ======= SECTIONS ======= */
|
||||||
.section { margin-bottom: 2rem; }
|
.section { margin-bottom: 1.5rem; }
|
||||||
.section h3 { margin-bottom: 0.75rem; font-size: 1rem; }
|
.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 {
|
.empty-state {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 3rem;
|
padding: 2.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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 {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status badges */
|
/* ======= STATUS BADGES ======= */
|
||||||
.badge-status-open { background: #3b2a2a; color: var(--danger); }
|
.badge-status-open {
|
||||||
.badge-status-resolved { background: #2a3b2a; color: var(--success); }
|
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 */
|
/* ======= CAMPAIGN BADGES ======= */
|
||||||
.badge-campaign-running { background: #2a2a3b; color: var(--accent); }
|
.badge-campaign-running {
|
||||||
.badge-campaign-finished { background: #2a3b2a; color: var(--success); }
|
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 {
|
.search-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.5rem 0.8rem;
|
||||||
background: var(--bg-surface);
|
background: #0a060a;
|
||||||
border: 1px solid var(--border);
|
border: 2px ridge #3a1828;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 10px var(--accent-glow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Crash group detail */
|
/* ======= CRASH GROUP DETAIL ======= */
|
||||||
.crashgroup-title {
|
.crashgroup-title {
|
||||||
font-size: 1.25rem;
|
font-family: var(--font-heading);
|
||||||
margin-bottom: 1.5rem;
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Regression */
|
/* ======= REGRESSION ======= */
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
.form-group { display: flex; flex-direction: column; gap: 0.375rem; flex: 1; }
|
.form-group { display: flex; flex-direction: column; gap: 0.3rem; flex: 1; }
|
||||||
.form-group label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
.form-group label {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.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 {
|
.regression-result {
|
||||||
border: 1px solid var(--border);
|
border: 2px ridge #3a1828;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.5rem;
|
padding: 1.25rem;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.25rem;
|
||||||
}
|
}
|
||||||
.regression-pass { border-color: var(--success); }
|
.regression-pass { border-color: var(--success); }
|
||||||
.regression-fail { border-color: var(--danger); }
|
.regression-fail {
|
||||||
.regression-verdict { font-size: 1.1rem; margin-bottom: 1rem; }
|
border-color: var(--danger);
|
||||||
.regression-fail .regression-verdict { 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); }
|
.regression-pass .regression-verdict { color: var(--success); }
|
||||||
.fingerprint-list { list-style: none; padding: 0; }
|
.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>
|
<body>
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
|
<span class="logo-sigil">ᛟ᛬ᛉ</span>
|
||||||
<h1 class="logo">Cairn</h1>
|
<h1 class="logo">Cairn</h1>
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/"><span class="nav-icon">ᛟ</span> Dashboard</a></li>
|
||||||
<li><a href="/artifacts">Artifacts</a></li>
|
<li><a href="/artifacts"><span class="nav-icon">ᛉ</span> Artifacts</a></li>
|
||||||
<li><a href="/crashgroups">Crash Groups</a></li>
|
<li><a href="/crashgroups"><span class="nav-icon">ᛞ</span> Crash Groups</a></li>
|
||||||
<li><a href="/campaigns">Campaigns</a></li>
|
<li><a href="/campaigns"><span class="nav-icon">ᛏ</span> Campaigns</a></li>
|
||||||
<li><a href="/repos">Repositories</a></li>
|
<hr class="sidebar-divider">
|
||||||
<li><a href="/regression">Regression</a></li>
|
<li><a href="/repos"><span class="nav-icon">ᚦ</span> Repositories</a></li>
|
||||||
<li><a href="/search">Search</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>
|
</ul>
|
||||||
|
<div class="sidebar-footer">the dead remember</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue