Break campaign into targets and runs

This commit is contained in:
Matthew Knight 2026-03-05 23:46:15 -08:00
parent 90d61f1a33
commit edff8885f0
No known key found for this signature in database
22 changed files with 1315 additions and 492 deletions

View File

@ -1,5 +1,5 @@
name: 'Cairn Zig Fuzz (AFL++)'
description: 'Install AFL++, then build and run AFL++ fuzz targets, reporting crashes to Cairn. Each line in zig_build_args is a separate target.'
description: 'Build and run Zig AFL++ fuzz targets, reporting crashes and corpus to Cairn. Each target is built via `zig build fuzz -Dfuzz-target=<name>`.'
inputs:
cairn_server:
@ -15,22 +15,18 @@ inputs:
description: 'Commit SHA'
required: false
default: '${{ github.sha }}'
zig_build_args:
targets:
description: |
Line-delimited zig build argument sets. Each line builds and fuzzes one target.
Line-delimited fuzz target names. Each line is passed as -Dfuzz-target=<name>.
Example:
fuzz -Dfuzz-target=lexer
fuzz -Dfuzz-target=parser
fuzz -Dfuzz-target=varint_decode
lexer
parser
varint_decode
required: true
fuzz_binary:
description: 'Binary name in zig-out/bin/ (auto-detected if only one)'
required: false
default: ''
corpus_dir:
description: 'Seed corpus directory (minimal seed created if empty)'
required: false
default: ''
duration:
description: 'Fuzz duration per target in seconds'
required: false
@ -60,15 +56,6 @@ runs:
set -eu
command -v zig >/dev/null 2>&1 || { echo "ERROR: zig not found in PATH. Install Zig before using this action (e.g. https://codeberg.org/mlugg/setup-zig@v2)."; exit 1; }
command -v afl-cc >/dev/null 2>&1 || { echo "ERROR: afl-cc not found in PATH after setup-afl step."; exit 1; }
if ! command -v jq >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y -qq jq >/dev/null 2>&1
else
echo "ERROR: jq not found and apt-get unavailable"
exit 1
fi
fi
echo "zig $(zig version), afl-cc found"
- name: Setup Cairn CLI
@ -84,62 +71,65 @@ runs:
REPO: ${{ inputs.repo }}
OWNER: ${{ inputs.owner }}
COMMIT: ${{ inputs.commit }}
ZIG_BUILD_ARGS: ${{ inputs.zig_build_args }}
TARGETS: ${{ inputs.targets }}
FUZZ_BINARY: ${{ inputs.fuzz_binary }}
CORPUS_DIR: ${{ inputs.corpus_dir }}
DURATION: ${{ inputs.duration }}
EXTRA_AFL_ARGS: ${{ inputs.afl_args }}
TARGET: ${{ inputs.target }}
TARGET_PLATFORM: ${{ inputs.target }}
run: |
set -eu
# ── Start Cairn campaign ──
SHORT_SHA=$(printf '%.8s' "${COMMIT}")
if [ -n "${TARGET}" ]; then
CAMPAIGN_NAME="fuzz-${TARGET}"
else
CAMPAIGN_NAME="fuzz-${SHORT_SHA}"
fi
CAMPAIGN_OUTPUT=$(cairn campaign start \
-server "${CAIRN_SERVER}" \
-repo "${REPO}" \
-owner "${OWNER}" \
-name "${CAMPAIGN_NAME}" \
-type fuzzing)
CAMPAIGN_ID="${CAMPAIGN_OUTPUT#Campaign started: }"
echo "Campaign ${CAMPAIGN_ID} started"
cleanup() {
cairn campaign finish -server "${CAIRN_SERVER}" -id "${CAMPAIGN_ID}" || true
}
trap cleanup EXIT
TOTAL_CRASHES=0
TARGET_NUM=0
# ── Iterate over each line of zig_build_args ──
while IFS= read -r BUILD_ARGS; do
# ── Iterate over each target name ──
while IFS= read -r FUZZ_TARGET; do
# Skip empty lines and comments
BUILD_ARGS=$(echo "${BUILD_ARGS}" | sed 's/#.*//' | xargs)
[ -z "${BUILD_ARGS}" ] && continue
FUZZ_TARGET=$(echo "${FUZZ_TARGET}" | sed 's/#.*//' | xargs)
[ -z "${FUZZ_TARGET}" ] && continue
TARGET_NUM=$((TARGET_NUM + 1))
BUILD_ARGS="fuzz -Dfuzz-target=${FUZZ_TARGET}"
echo ""
echo "=========================================="
echo "Target ${TARGET_NUM}: zig build ${BUILD_ARGS}"
echo "Target ${TARGET_NUM}: ${FUZZ_TARGET} (zig build ${BUILD_ARGS})"
echo "=========================================="
# Special-case Zig fuzz targets: if build args contain -Dfuzz-target=<name>,
# use that as the effective Cairn target for metadata/track keying.
LINE_FUZZ_TARGET=$(printf '%s' "${BUILD_ARGS}" | sed -n 's/.*-Dfuzz-target=\([^[:space:]]*\).*/\1/p')
EFFECTIVE_TARGET="${TARGET}"
if [ -n "${LINE_FUZZ_TARGET}" ]; then
EFFECTIVE_TARGET="${LINE_FUZZ_TARGET}"
fi
# ── Ensure Cairn target ──
CAIRN_TARGET_ID=$(cairn target ensure \
-server "${CAIRN_SERVER}" \
-repo "${REPO}" \
-owner "${OWNER}" \
-name "${FUZZ_TARGET}" \
-type fuzz)
echo "Cairn target ID: ${CAIRN_TARGET_ID}"
TRACK_SOURCE="${OWNER}/${REPO}|${EFFECTIVE_TARGET}|${BUILD_ARGS}"
TRACK_KEY=$(printf '%s' "${TRACK_SOURCE}" | sha256sum | awk '{print $1}')
echo "Track key: ${TRACK_KEY}"
# ── Start a run ──
RUN_ID=$(cairn run start \
-server "${CAIRN_SERVER}" \
-target-id "${CAIRN_TARGET_ID}" \
-commit "${COMMIT}")
echo "Run ID: ${RUN_ID}"
finish_run() {
cairn run finish -server "${CAIRN_SERVER}" -id "${RUN_ID}" || true
}
trap finish_run EXIT
# ── Download existing corpus ──
SEEDS="afl-seeds-${TARGET_NUM}"
rm -rf "${SEEDS}"
mkdir -p "${SEEDS}"
echo "Downloading existing corpus..."
cairn corpus download \
-server "${CAIRN_SERVER}" \
-target-id "${CAIRN_TARGET_ID}" \
-dir "${SEEDS}" || true
if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then
printf 'A' > "${SEEDS}/seed-0"
fi
# ── Build ──
rm -rf zig-out
@ -167,31 +157,6 @@ runs:
fi
echo "Fuzz binary: ${FUZZ_BIN}"
# ── Seed corpus ──
SEEDS="afl-seeds-${TARGET_NUM}"
rm -rf "${SEEDS}"
mkdir -p "${SEEDS}"
if [ -n "${CORPUS_DIR}" ] && [ -d "${CORPUS_DIR}" ]; then
cp -a "${CORPUS_DIR}/." "${SEEDS}/" 2>/dev/null || true
fi
LATEST_CORPUS_ID=$(curl -fsSL "${CAIRN_SERVER}/api/v1/artifacts?type=fuzz&limit=200" \
| jq -r --arg track "${TRACK_KEY}" --arg repo "${REPO}" \
'.artifacts[]? | select(.repo_name == $repo and (.metadata.kind // "") == "corpus" and (.metadata.track_key // "") == $track) | .id' \
| head -n1)
if [ -n "${LATEST_CORPUS_ID}" ] && [ "${LATEST_CORPUS_ID}" != "null" ]; then
echo "Downloading prior corpus artifact: ${LATEST_CORPUS_ID}"
if cairn download -server "${CAIRN_SERVER}" -id "${LATEST_CORPUS_ID}" -o "prior-corpus-${TARGET_NUM}.tar.gz"; then
tar xzf "prior-corpus-${TARGET_NUM}.tar.gz" -C "${SEEDS}" || true
rm -f "prior-corpus-${TARGET_NUM}.tar.gz"
fi
fi
if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then
printf 'A' > "${SEEDS}/seed-0"
fi
# ── Run AFL++ ──
FINDINGS="findings-${TARGET_NUM}"
rm -rf "${FINDINGS}"
@ -234,12 +199,12 @@ runs:
echo "Uploading crash: ${CRASH_NAME}"
set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \
-commit "${COMMIT}" -campaign-id "${CAMPAIGN_ID}" -type fuzz -file "${crash_file}" \
-kind crash -track-key "${TRACK_KEY}" \
-crash-message "AFL++ crash (${BUILD_ARGS}): ${CRASH_NAME}"
-commit "${COMMIT}" -run-id "${RUN_ID}" -type fuzz -file "${crash_file}" \
-kind crash \
-crash-message "AFL++ crash (${FUZZ_TARGET}): ${CRASH_NAME}"
if [ -n "${EFFECTIVE_TARGET}" ]; then
set -- "$@" -target "${EFFECTIVE_TARGET}"
if [ -n "${TARGET_PLATFORM}" ]; then
set -- "$@" -target "${TARGET_PLATFORM}"
fi
if [ -n "${SIG}" ]; then
set -- "$@" -signal "${SIG}"
@ -250,29 +215,26 @@ runs:
done
fi
# ── Upload corpus ──
# ── Upload new corpus entries ──
QUEUE_DIR="${FINDINGS}/default/queue"
if [ -d "${QUEUE_DIR}" ]; then
QUEUE_COUNT=$(find "${QUEUE_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l)
if [ "${QUEUE_COUNT}" -gt 0 ]; then
echo "Uploading corpus (${QUEUE_COUNT} entries)..."
tar czf "corpus-${TARGET_NUM}.tar.gz" -C "${QUEUE_DIR}" .
set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \
-commit "${COMMIT}" -campaign-id "${CAMPAIGN_ID}" -type fuzz \
-kind corpus -track-key "${TRACK_KEY}" \
-file "corpus-${TARGET_NUM}.tar.gz"
if [ -n "${EFFECTIVE_TARGET}" ]; then
set -- "$@" -target "${EFFECTIVE_TARGET}"
fi
cairn upload "$@"
rm -f "corpus-${TARGET_NUM}.tar.gz"
cairn corpus upload \
-server "${CAIRN_SERVER}" \
-target-id "${CAIRN_TARGET_ID}" \
-run-id "${RUN_ID}" \
-dir "${QUEUE_DIR}"
fi
fi
# ── Finish run ──
finish_run
trap - EXIT
done <<EOF
${ZIG_BUILD_ARGS}
${TARGETS}
EOF
# ── Final report ──

View File

@ -133,18 +133,32 @@ func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
serverURL, db, cleanup, _ := setupCLIServer(t, false)
defer cleanup()
if err := cmdCampaign("start", []string{
// Create a target first.
if err := cmdTarget("ensure", []string{
"-server", serverURL,
"-repo", "demo",
"-owner", "acme",
"-name", "seed-campaign",
"-name", "seed-target",
"-type", "fuzz",
}); err != nil {
t.Fatalf("cmdCampaign start failed: %v", err)
t.Fatalf("cmdTarget ensure failed: %v", err)
}
var campaign models.Campaign
if err := db.First(&campaign).Error; err != nil {
t.Fatalf("querying campaign: %v", err)
var target models.Target
if err := db.First(&target).Error; err != nil {
t.Fatalf("querying target: %v", err)
}
// Start a run.
if err := cmdRun("start", []string{
"-server", serverURL,
"-target-id", strconv.FormatUint(uint64(target.ID), 10),
"-commit", "abcdef1234567890",
}); err != nil {
t.Fatalf("cmdRun start failed: %v", err)
}
var run models.Run
if err := db.First(&run).Error; err != nil {
t.Fatalf("querying run: %v", err)
}
artifactFile := filepath.Join(t.TempDir(), "artifact.bin")
@ -158,7 +172,7 @@ func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
"-repo", "demo",
"-owner", "acme",
"-commit", "abcdef1234567890",
"-campaign-id", strconv.FormatUint(uint64(campaign.ID), 10),
"-run-id", strconv.FormatUint(uint64(run.ID), 10),
"-type", "fuzz",
"-file", artifactFile,
"-crash-message", "boom",
@ -188,8 +202,8 @@ func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
if got := md["track_key"]; got != "track-123" {
t.Fatalf("expected metadata.track_key=track-123, got %#v", got)
}
if a.CampaignID == nil || *a.CampaignID != campaign.ID {
t.Fatalf("expected campaign_id=%d, got %#v", campaign.ID, a.CampaignID)
if a.RunID == nil || *a.RunID != run.ID {
t.Fatalf("expected run_id=%d, got %#v", run.ID, a.RunID)
}
outFile := filepath.Join(t.TempDir(), "downloaded.bin")
@ -206,40 +220,73 @@ func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
}
}
func TestCLICampaignStartAndFinish(t *testing.T) {
func TestCLITargetEnsureAndRunStartFinish(t *testing.T) {
serverURL, db, cleanup, _ := setupCLIServer(t, false)
defer cleanup()
if err := cmdCampaign("start", []string{
if err := cmdTarget("ensure", []string{
"-server", serverURL,
"-repo", "demo",
"-owner", "acme",
"-name", "nightly",
"-type", "fuzz",
}); err != nil {
t.Fatalf("cmdCampaign start failed: %v", err)
t.Fatalf("cmdTarget ensure failed: %v", err)
}
var campaign models.Campaign
if err := db.First(&campaign).Error; err != nil {
t.Fatalf("querying campaign: %v", err)
var target models.Target
if err := db.First(&target).Error; err != nil {
t.Fatalf("querying target: %v", err)
}
if campaign.Status != "running" {
t.Fatalf("expected running campaign, got %q", campaign.Status)
if target.Name != "nightly" {
t.Fatalf("expected target name=nightly, got %q", target.Name)
}
if err := cmdCampaign("finish", []string{
// Ensure is idempotent.
if err := cmdTarget("ensure", []string{
"-server", serverURL,
"-id", strconv.FormatUint(uint64(campaign.ID), 10),
"-repo", "demo",
"-owner", "acme",
"-name", "nightly",
"-type", "fuzz",
}); err != nil {
t.Fatalf("cmdCampaign finish failed: %v", err)
t.Fatalf("cmdTarget ensure (idempotent) failed: %v", err)
}
var count int64
db.Model(&models.Target{}).Count(&count)
if count != 1 {
t.Fatalf("expected 1 target after idempotent ensure, got %d", count)
}
if err := db.First(&campaign, campaign.ID).Error; err != nil {
t.Fatalf("re-querying campaign: %v", err)
// Start a run.
if err := cmdRun("start", []string{
"-server", serverURL,
"-target-id", strconv.FormatUint(uint64(target.ID), 10),
"-commit", "abc123",
}); err != nil {
t.Fatalf("cmdRun start failed: %v", err)
}
if campaign.Status != "finished" {
t.Fatalf("expected finished campaign, got %q", campaign.Status)
var run models.Run
if err := db.First(&run).Error; err != nil {
t.Fatalf("querying run: %v", err)
}
if run.Status != "running" {
t.Fatalf("expected running run, got %q", run.Status)
}
if err := cmdRun("finish", []string{
"-server", serverURL,
"-id", strconv.FormatUint(uint64(run.ID), 10),
}); err != nil {
t.Fatalf("cmdRun finish failed: %v", err)
}
if err := db.First(&run, run.ID).Error; err != nil {
t.Fatalf("re-querying run: %v", err)
}
if run.Status != "finished" {
t.Fatalf("expected finished run, got %q", run.Status)
}
}

View File

@ -34,12 +34,30 @@ func main() {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
case "campaign":
case "target":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "usage: cairn campaign <start|finish>\n")
fmt.Fprintf(os.Stderr, "usage: cairn target <ensure>\n")
os.Exit(1)
}
if err := cmdCampaign(os.Args[2], os.Args[3:]); err != nil {
if err := cmdTarget(os.Args[2], os.Args[3:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
case "run":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "usage: cairn run <start|finish>\n")
os.Exit(1)
}
if err := cmdRun(os.Args[2], os.Args[3:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
case "corpus":
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "usage: cairn corpus <upload|download>\n")
os.Exit(1)
}
if err := cmdCorpus(os.Args[2], os.Args[3:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
@ -59,10 +77,13 @@ func usage() {
fmt.Fprintf(os.Stderr, `Usage: cairn <command> [args]
Commands:
upload Upload an artifact to Cairn
upload Upload a crash artifact
check Check for regressions between two commits
campaign start Start a new campaign
campaign finish Finish a running campaign
target ensure Get or create a target (idempotent)
run start Start a new run for a target
run finish Finish a running run
corpus upload Upload a corpus entry to a target
corpus download Download corpus entries from a target
download Download an artifact
Upload flags:
@ -70,7 +91,7 @@ Upload flags:
-repo NAME Repository name (required)
-owner OWNER Repository owner (required)
-commit SHA Commit SHA (required)
-campaign-id ID Campaign ID (optional)
-run-id ID Run ID (optional)
-type TYPE Artifact type: coredump, fuzz, sanitizer, simulation (required)
-file PATH Path to artifact file (required)
-crash-message MSG Crash message (optional)
@ -89,7 +110,7 @@ func cmdUpload(args []string) error {
repo string
owner string
commitSHA string
campaignID string
runID string
artifactType string
filePath string
crashMessage string
@ -115,9 +136,9 @@ func cmdUpload(args []string) error {
case "-commit":
i++
commitSHA = args[i]
case "-campaign-id":
case "-run-id":
i++
campaignID = args[i]
runID = args[i]
case "-type":
i++
artifactType = args[i]
@ -160,12 +181,12 @@ func cmdUpload(args []string) error {
"commit_sha": commitSHA,
"type": artifactType,
}
if campaignID != "" {
id, err := strconv.ParseUint(campaignID, 10, 64)
if runID != "" {
id, err := strconv.ParseUint(runID, 10, 64)
if err != nil {
return fmt.Errorf("invalid campaign id: %w", err)
return fmt.Errorf("invalid run id: %w", err)
}
meta["campaign_id"] = id
meta["run_id"] = id
}
if crashMessage != "" {
meta["crash_message"] = crashMessage
@ -316,12 +337,12 @@ func cmdCheck(args []string) error {
return nil
}
func cmdCampaign(subcmd string, args []string) error {
func cmdTarget(subcmd string, args []string) error {
serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080")
switch subcmd {
case "start":
var repo, owner, name, ctype string
case "ensure":
var repo, owner, name, ttype string
for i := 0; i < len(args); i++ {
switch args[i] {
case "-server":
@ -338,18 +359,71 @@ func cmdCampaign(subcmd string, args []string) error {
name = args[i]
case "-type":
i++
ctype = args[i]
ttype = args[i]
default:
return fmt.Errorf("unknown flag: %s", args[i])
}
}
if repo == "" || owner == "" || name == "" || ctype == "" {
if repo == "" || owner == "" || name == "" || ttype == "" {
return fmt.Errorf("required flags: -repo, -owner, -name, -type")
}
body, _ := json.Marshal(map[string]string{
"repository": repo, "owner": owner, "name": name, "type": ctype,
"repository": repo, "owner": owner, "name": name, "type": ttype,
})
resp, err := http.Post(serverURL+"/api/v1/campaigns", "application/json", bytes.NewReader(body))
resp, err := http.Post(serverURL+"/api/v1/targets", "application/json", bytes.NewReader(body))
if err != nil {
return 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 map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("parsing response: %w", err)
}
// Print just the ID for easy capture in scripts.
fmt.Printf("%v\n", result["id"])
default:
return fmt.Errorf("unknown target subcommand: %s (use ensure)", subcmd)
}
return nil
}
func cmdRun(subcmd string, args []string) error {
serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080")
switch subcmd {
case "start":
var targetID, commitSHA string
for i := 0; i < len(args); i++ {
switch args[i] {
case "-server":
i++
serverURL = args[i]
case "-target-id":
i++
targetID = args[i]
case "-commit":
i++
commitSHA = args[i]
default:
return fmt.Errorf("unknown flag: %s", args[i])
}
}
if targetID == "" || commitSHA == "" {
return fmt.Errorf("required flags: -target-id, -commit")
}
tid, err := strconv.ParseUint(targetID, 10, 64)
if err != nil {
return fmt.Errorf("invalid target id: %w", err)
}
body, _ := json.Marshal(map[string]any{
"target_id": tid, "commit_sha": commitSHA,
})
resp, err := http.Post(serverURL+"/api/v1/runs", "application/json", bytes.NewReader(body))
if err != nil {
return err
}
@ -362,7 +436,8 @@ func cmdCampaign(subcmd string, args []string) error {
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("parsing response: %w", err)
}
fmt.Printf("Campaign started: %v\n", result["id"])
// Print just the ID for easy capture in scripts.
fmt.Printf("%v\n", result["id"])
case "finish":
var id string
@ -381,7 +456,7 @@ func cmdCampaign(subcmd string, args []string) error {
if id == "" {
return fmt.Errorf("required flag: -id")
}
resp, err := http.Post(serverURL+"/api/v1/campaigns/"+id+"/finish", "application/json", nil)
resp, err := http.Post(serverURL+"/api/v1/runs/"+id+"/finish", "application/json", nil)
if err != nil {
return err
}
@ -390,10 +465,174 @@ func cmdCampaign(subcmd string, args []string) error {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
}
fmt.Println("Campaign finished")
fmt.Println("Run finished")
default:
return fmt.Errorf("unknown campaign subcommand: %s (use start or finish)", subcmd)
return fmt.Errorf("unknown run subcommand: %s (use start or finish)", subcmd)
}
return nil
}
func cmdCorpus(subcmd string, args []string) error {
serverURL := envOr("CAIRN_SERVER_URL", "http://localhost:8080")
switch subcmd {
case "upload":
var targetID, runID, dir string
for i := 0; i < len(args); i++ {
switch args[i] {
case "-server":
i++
serverURL = args[i]
case "-target-id":
i++
targetID = args[i]
case "-run-id":
i++
runID = args[i]
case "-dir":
i++
dir = args[i]
default:
return fmt.Errorf("unknown flag: %s", args[i])
}
}
if targetID == "" || dir == "" {
return fmt.Errorf("required flags: -target-id, -dir")
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("reading directory: %w", err)
}
var uploaded int
for _, entry := range entries {
if entry.IsDir() {
continue
}
filePath := filepath.Join(dir, entry.Name())
if err := uploadCorpusFile(serverURL, targetID, runID, filePath); err != nil {
return fmt.Errorf("uploading %s: %w", entry.Name(), err)
}
uploaded++
}
fmt.Printf("Uploaded %d corpus entries\n", uploaded)
case "download":
var targetID, dir string
for i := 0; i < len(args); i++ {
switch args[i] {
case "-server":
i++
serverURL = args[i]
case "-target-id":
i++
targetID = args[i]
case "-dir":
i++
dir = args[i]
default:
return fmt.Errorf("unknown flag: %s", args[i])
}
}
if targetID == "" || dir == "" {
return fmt.Errorf("required flags: -target-id, -dir")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating output dir: %w", err)
}
// List all corpus entries.
resp, err := http.Get(serverURL + "/api/v1/targets/" + targetID + "/corpus?limit=10000")
if err != nil {
return fmt.Errorf("listing corpus: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
}
var listResp struct {
Entries []struct {
ID float64 `json:"id"`
BlobKey string `json:"blob_key"`
} `json:"entries"`
}
if err := json.Unmarshal(body, &listResp); err != nil {
return fmt.Errorf("parsing response: %w", err)
}
var downloaded int
for _, entry := range listResp.Entries {
entryID := fmt.Sprintf("%d", int(entry.ID))
dlURL := serverURL + "/api/v1/targets/" + targetID + "/corpus/" + entryID + "/download"
dlResp, err := http.Get(dlURL)
if err != nil {
return fmt.Errorf("downloading entry %s: %w", entryID, err)
}
filename := filepath.Base(entry.BlobKey)
outPath := filepath.Join(dir, filename)
out, err := os.Create(outPath)
if err != nil {
dlResp.Body.Close()
return fmt.Errorf("creating file %s: %w", outPath, err)
}
_, err = io.Copy(out, dlResp.Body)
dlResp.Body.Close()
out.Close()
if err != nil {
return fmt.Errorf("writing file %s: %w", outPath, err)
}
downloaded++
}
fmt.Printf("Downloaded %d corpus entries to %s\n", downloaded, dir)
default:
return fmt.Errorf("unknown corpus subcommand: %s (use upload or download)", subcmd)
}
return nil
}
func uploadCorpusFile(serverURL, targetID, runID, filePath string) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if runID != "" {
if err := w.WriteField("run_id", runID); err != nil {
return err
}
}
fw, err := w.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
return err
}
if _, err := io.Copy(fw, f); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
resp, err := http.Post(serverURL+"/api/v1/targets/"+targetID+"/corpus", w.FormDataContentType(), &buf)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
}
return nil
}

View File

@ -29,7 +29,7 @@ type Artifact struct {
RepositoryID uint `json:"repository_id"`
CommitID uint `json:"commit_id"`
BuildID *uint `json:"build_id,omitempty"`
CampaignID *uint `json:"campaign_id,omitempty"`
RunID *uint `json:"run_id,omitempty"`
CrashSignatureID *uint `json:"crash_signature_id,omitempty"`
Type string `json:"type"`
BlobKey string `json:"blob_key"`
@ -44,22 +44,48 @@ type Artifact struct {
CommitSHA string `json:"commit_sha,omitempty"`
}
type Campaign struct {
type Target struct {
ID uint `json:"id"`
RepositoryID uint `json:"repository_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Tags json.RawMessage `json:"tags,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RepoName string `json:"repo_name,omitempty"`
RunCount int64 `json:"run_count,omitempty"`
CorpusCount int64 `json:"corpus_count,omitempty"`
}
type Run struct {
ID uint `json:"id"`
TargetID uint `json:"target_id"`
CommitID uint `json:"commit_id"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Tags json.RawMessage `json:"tags,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
TargetName string `json:"target_name,omitempty"`
RepoName string `json:"repo_name,omitempty"`
CommitSHA string `json:"commit_sha,omitempty"`
ArtifactCount int64 `json:"artifact_count,omitempty"`
}
type CorpusEntry struct {
ID uint `json:"id"`
TargetID uint `json:"target_id"`
RunID *uint `json:"run_id,omitempty"`
BlobKey string `json:"blob_key"`
BlobSize int64 `json:"blob_size"`
Fingerprint *string `json:"fingerprint,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type CrashSignature struct {
ID uint `json:"id"`
RepositoryID uint `json:"repository_id"`

View File

@ -12,7 +12,9 @@ func Migrate(db *gorm.DB) error {
&models.Repository{},
&models.Commit{},
&models.Build{},
&models.Campaign{},
&models.Target{},
&models.Run{},
&models.CorpusEntry{},
&models.CrashSignature{},
&models.CrashGroup{},
&models.Artifact{},

View File

@ -1,110 +0,0 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
cairnapi "github.com/mattnite/cairn/internal/api"
"github.com/mattnite/cairn/internal/models"
"gorm.io/gorm"
)
type CampaignHandler struct {
DB *gorm.DB
}
func (h *CampaignHandler) List(c *gin.Context) {
limit, _ := strconv.Atoi(c.Query("limit"))
offset, _ := strconv.Atoi(c.Query("offset"))
if limit <= 0 {
limit = 50
}
repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
campaigns, total, err := models.ListCampaigns(c.Request.Context(), h.DB, repoID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if campaigns == nil {
campaigns = []cairnapi.Campaign{}
}
c.JSON(http.StatusOK, gin.H{
"campaigns": campaigns,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *CampaignHandler) Detail(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "campaign id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
campaign, err := models.GetCampaign(c.Request.Context(), h.DB, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "campaign not found"})
return
}
c.JSON(http.StatusOK, campaign)
}
type CreateCampaignRequest struct {
Repository string `json:"repository" binding:"required"`
Owner string `json:"owner" binding:"required"`
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
}
func (h *CampaignHandler) Create(c *gin.Context) {
var req CreateCampaignRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
repo, err := models.GetOrCreateRepository(ctx, h.DB, req.Owner, req.Repository)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
campaign, err := models.CreateCampaign(ctx, h.DB, models.CreateCampaignParams{
RepositoryID: repo.ID,
Name: req.Name,
Type: req.Type,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, campaign)
}
func (h *CampaignHandler) Finish(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "campaign id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := models.FinishCampaign(c.Request.Context(), h.DB, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "finished"})
}

View File

@ -18,7 +18,7 @@ type DashboardStats struct {
TotalRepos int64 `json:"total_repos"`
TotalCrashGroups int64 `json:"total_crash_groups"`
OpenCrashGroups int64 `json:"open_crash_groups"`
ActiveCampaigns int64 `json:"active_campaigns"`
TotalTargets int64 `json:"total_targets"`
}
type TrendPoint struct {
@ -47,7 +47,7 @@ func (h *DashboardHandler) Stats(c *gin.Context) {
_ = h.DB.WithContext(ctx).Model(&models.Repository{}).Count(&stats.TotalRepos).Error
_ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Count(&stats.TotalCrashGroups).Error
_ = h.DB.WithContext(ctx).Model(&models.CrashGroup{}).Where("status = ?", "open").Count(&stats.OpenCrashGroups).Error
_ = h.DB.WithContext(ctx).Model(&models.Campaign{}).Where("status = ?", "running").Count(&stats.ActiveCampaigns).Error
_ = h.DB.WithContext(ctx).Model(&models.Target{}).Count(&stats.TotalTargets).Error
// Artifact trend for the last 30 days.
var trend []TrendPoint

View File

@ -25,7 +25,7 @@ type IngestRequest struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
CommitSHA string `json:"commit_sha"`
CampaignID *uint `json:"campaign_id,omitempty"`
RunID *uint `json:"run_id,omitempty"`
Type string `json:"type"`
CrashMessage string `json:"crash_message,omitempty"`
StackTrace string `json:"stack_trace,omitempty"`
@ -94,7 +94,7 @@ func (h *IngestHandler) Create(c *gin.Context) {
artifact, err := models.CreateArtifact(ctx, h.DB, models.CreateArtifactParams{
RepositoryID: repo.ID,
CommitID: commit.ID,
CampaignID: req.CampaignID,
RunID: req.RunID,
Type: req.Type,
BlobKey: blobKey,
BlobSize: header.Size,

319
internal/handler/targets.go Normal file
View File

@ -0,0 +1,319 @@
package handler
import (
"fmt"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
cairnapi "github.com/mattnite/cairn/internal/api"
"github.com/mattnite/cairn/internal/blob"
"github.com/mattnite/cairn/internal/models"
"gorm.io/gorm"
)
type TargetHandler struct {
DB *gorm.DB
Store blob.Store
}
func (h *TargetHandler) List(c *gin.Context) {
limit, _ := strconv.Atoi(c.Query("limit"))
offset, _ := strconv.Atoi(c.Query("offset"))
if limit <= 0 {
limit = 50
}
repoID, err := parseOptionalUintID(c.Query("repository_id"), "repository_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
targets, total, err := models.ListTargets(c.Request.Context(), h.DB, repoID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if targets == nil {
targets = []cairnapi.Target{}
}
c.JSON(http.StatusOK, gin.H{
"targets": targets,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *TargetHandler) Detail(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "target id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
target, err := models.GetTarget(c.Request.Context(), h.DB, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "target not found"})
return
}
c.JSON(http.StatusOK, target)
}
type EnsureTargetRequest struct {
Repository string `json:"repository" binding:"required"`
Owner string `json:"owner" binding:"required"`
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
}
func (h *TargetHandler) Ensure(c *gin.Context) {
var req EnsureTargetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
repo, err := models.GetOrCreateRepository(ctx, h.DB, req.Owner, req.Repository)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
target, err := models.GetOrCreateTarget(ctx, h.DB, models.CreateTargetParams{
RepositoryID: repo.ID,
Name: req.Name,
Type: req.Type,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, target)
}
// Run handlers
type RunHandler struct {
DB *gorm.DB
}
type StartRunRequest struct {
TargetID uint `json:"target_id" binding:"required"`
CommitSHA string `json:"commit_sha" binding:"required"`
}
func (h *RunHandler) Start(c *gin.Context) {
var req StartRunRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
// Look up the target to get the repository ID for the commit.
target, err := models.GetTarget(ctx, h.DB, req.TargetID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "target not found"})
return
}
commit := &models.Commit{RepositoryID: target.RepositoryID, SHA: req.CommitSHA}
if err := h.DB.WithContext(ctx).Where("repository_id = ? AND sha = ?", target.RepositoryID, req.CommitSHA).FirstOrCreate(commit).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
run, err := models.CreateRun(ctx, h.DB, req.TargetID, commit.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, run)
}
func (h *RunHandler) Finish(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "run id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := models.FinishRun(c.Request.Context(), h.DB, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "finished"})
}
func (h *RunHandler) Detail(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "run id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
run, err := models.GetRun(c.Request.Context(), h.DB, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
return
}
c.JSON(http.StatusOK, run)
}
func (h *RunHandler) List(c *gin.Context) {
limit, _ := strconv.Atoi(c.Query("limit"))
offset, _ := strconv.Atoi(c.Query("offset"))
if limit <= 0 {
limit = 50
}
targetID, err := parseOptionalUintID(c.Query("target_id"), "target_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
runs, total, err := models.ListRuns(c.Request.Context(), h.DB, targetID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if runs == nil {
runs = []cairnapi.Run{}
}
c.JSON(http.StatusOK, gin.H{
"runs": runs,
"total": total,
"limit": limit,
"offset": offset,
})
}
// Corpus handlers
type CorpusHandler struct {
DB *gorm.DB
Store blob.Store
}
func (h *CorpusHandler) Upload(c *gin.Context) {
targetID, err := parseUintID(c.Param("id"), "target id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
// Verify target exists.
target, err := models.GetTarget(ctx, h.DB, targetID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "target not found"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'file' form field: " + err.Error()})
return
}
defer file.Close()
var runID *uint
if rid := c.PostForm("run_id"); rid != "" {
id, err := strconv.ParseUint(rid, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid run_id"})
return
}
uid := uint(id)
runID = &uid
}
blobKey := fmt.Sprintf("corpus/%s/%s/%s", target.RepoName, target.Name, header.Filename)
if err := h.Store.Put(ctx, blobKey, file, header.Size); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "storing blob: " + err.Error()})
return
}
entry, err := models.CreateCorpusEntry(ctx, h.DB, models.CreateCorpusEntryParams{
TargetID: targetID,
RunID: runID,
BlobKey: blobKey,
BlobSize: header.Size,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, entry)
}
func (h *CorpusHandler) List(c *gin.Context) {
targetID, err := parseUintID(c.Param("id"), "target id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
offset, _ := strconv.Atoi(c.Query("offset"))
entries, total, err := models.ListCorpusEntries(c.Request.Context(), h.DB, targetID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if entries == nil {
entries = []cairnapi.CorpusEntry{}
}
c.JSON(http.StatusOK, gin.H{
"entries": entries,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *CorpusHandler) Download(c *gin.Context) {
entryID, err := parseUintID(c.Param("entry_id"), "corpus entry id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
entry := &models.CorpusEntry{}
if err := h.DB.WithContext(ctx).First(entry, entryID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "corpus entry not found"})
return
}
reader, err := h.Store.Get(ctx, entry.BlobKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "reading blob: " + err.Error()})
return
}
defer reader.Close()
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", entry.BlobKey))
c.Header("Content-Type", "application/octet-stream")
_, _ = io.Copy(c.Writer, reader)
}

View File

@ -13,7 +13,7 @@ type CreateArtifactParams struct {
RepositoryID uint
CommitID uint
BuildID *uint
CampaignID *uint
RunID *uint
Type string
BlobKey string
BlobSize int64
@ -35,7 +35,7 @@ func CreateArtifact(ctx context.Context, db *gorm.DB, p CreateArtifactParams) (*
RepositoryID: p.RepositoryID,
CommitID: p.CommitID,
BuildID: p.BuildID,
CampaignID: p.CampaignID,
RunID: p.RunID,
Type: p.Type,
BlobKey: p.BlobKey,
BlobSize: p.BlobSize,
@ -64,7 +64,7 @@ type ListArtifactsParams struct {
CommitSHA string
Type string
SignatureID *uint
CampaignID *uint
RunID *uint
Limit int
Offset int
}
@ -85,8 +85,8 @@ func ListArtifacts(ctx context.Context, db *gorm.DB, p ListArtifactsParams) ([]c
if p.SignatureID != nil {
query = query.Where("crash_signature_id = ?", *p.SignatureID)
}
if p.CampaignID != nil {
query = query.Where("campaign_id = ?", *p.CampaignID)
if p.RunID != nil {
query = query.Where("run_id = ?", *p.RunID)
}
if p.CommitSHA != "" {
query = query.Joins("JOIN commits ON commits.id = artifacts.commit_id").Where("commits.sha = ?", p.CommitSHA)
@ -167,7 +167,7 @@ func artifactFromModel(m Artifact) cairnapi.Artifact {
RepositoryID: m.RepositoryID,
CommitID: m.CommitID,
BuildID: m.BuildID,
CampaignID: m.CampaignID,
RunID: m.RunID,
CrashSignatureID: m.CrashSignatureID,
Type: m.Type,
BlobKey: m.BlobKey,

View File

@ -1,125 +0,0 @@
package models
import (
"context"
"encoding/json"
"fmt"
"time"
cairnapi "github.com/mattnite/cairn/internal/api"
"gorm.io/gorm"
)
type CreateCampaignParams struct {
RepositoryID uint
Name string
Type string
Tags json.RawMessage
Metadata json.RawMessage
}
func CreateCampaign(ctx context.Context, db *gorm.DB, p CreateCampaignParams) (*cairnapi.Campaign, error) {
if p.Tags == nil {
p.Tags = json.RawMessage("{}")
}
if p.Metadata == nil {
p.Metadata = json.RawMessage("{}")
}
campaign := &Campaign{
RepositoryID: p.RepositoryID,
Name: p.Name,
Type: p.Type,
Status: "running",
StartedAt: time.Now(),
Tags: p.Tags,
Metadata: p.Metadata,
}
if err := db.WithContext(ctx).Create(campaign).Error; err != nil {
return nil, fmt.Errorf("creating campaign: %w", err)
}
return enrichCampaign(ctx, db, *campaign)
}
func FinishCampaign(ctx context.Context, db *gorm.DB, id uint) error {
now := time.Now()
if err := db.WithContext(ctx).Model(&Campaign{}).Where("id = ?", id).Updates(map[string]any{
"status": "finished",
"finished_at": now,
}).Error; err != nil {
return fmt.Errorf("finishing campaign: %w", err)
}
return nil
}
func GetCampaign(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Campaign, error) {
campaign := &Campaign{}
if err := db.WithContext(ctx).First(campaign, id).Error; err != nil {
return nil, fmt.Errorf("getting campaign: %w", err)
}
return enrichCampaign(ctx, db, *campaign)
}
func ListCampaigns(ctx context.Context, db *gorm.DB, repoID *uint, limit, offset int) ([]cairnapi.Campaign, int64, error) {
if limit <= 0 {
limit = 50
}
query := db.WithContext(ctx).Model(&Campaign{})
if repoID != nil {
query = query.Where("repository_id = ?", *repoID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting campaigns: %w", err)
}
var dbCampaigns []Campaign
if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&dbCampaigns).Error; err != nil {
return nil, 0, fmt.Errorf("listing campaigns: %w", err)
}
campaigns := make([]cairnapi.Campaign, 0, len(dbCampaigns))
for _, m := range dbCampaigns {
c, err := enrichCampaign(ctx, db, m)
if err != nil {
return nil, 0, err
}
campaigns = append(campaigns, *c)
}
return campaigns, total, nil
}
func enrichCampaign(ctx context.Context, db *gorm.DB, model Campaign) (*cairnapi.Campaign, error) {
repo := &Repository{}
if err := db.WithContext(ctx).First(repo, model.RepositoryID).Error; err != nil {
return nil, fmt.Errorf("loading campaign repository: %w", err)
}
var count int64
if err := db.WithContext(ctx).Model(&Artifact{}).Where("campaign_id = ?", model.ID).Count(&count).Error; err != nil {
return nil, fmt.Errorf("counting campaign artifacts: %w", err)
}
campaign := campaignFromModel(model)
campaign.RepoName = repo.Name
campaign.ArtifactCount = count
return &campaign, nil
}
func campaignFromModel(m Campaign) cairnapi.Campaign {
return cairnapi.Campaign{
ID: m.ID,
RepositoryID: m.RepositoryID,
Name: m.Name,
Type: m.Type,
Status: m.Status,
StartedAt: m.StartedAt,
FinishedAt: m.FinishedAt,
Tags: m.Tags,
Metadata: m.Metadata,
CreatedAt: m.CreatedAt,
}
}

View File

@ -50,7 +50,7 @@ type Artifact struct {
RepositoryID uint `gorm:"not null;index"`
CommitID uint `gorm:"not null;index"`
BuildID *uint `gorm:"index"`
CampaignID *uint `gorm:"index"`
RunID *uint `gorm:"index"`
CrashSignatureID *uint `gorm:"index"`
Type string `gorm:"not null;index"`
BlobKey string `gorm:"not null"`
@ -64,28 +64,58 @@ type Artifact struct {
Repository Repository `gorm:"foreignKey:RepositoryID"`
Commit Commit `gorm:"foreignKey:CommitID"`
Build *Build `gorm:"foreignKey:BuildID"`
Campaign *Campaign `gorm:"foreignKey:CampaignID"`
Run *Run `gorm:"foreignKey:RunID"`
Signature *CrashSignature `gorm:"foreignKey:CrashSignatureID"`
}
func (Artifact) TableName() string { return "artifacts" }
type Campaign struct {
ID uint `gorm:"primaryKey"`
RepositoryID uint `gorm:"not null;index"`
Name string `gorm:"not null"`
Type string `gorm:"not null"`
Status string `gorm:"not null;default:running;index"`
StartedAt time.Time `gorm:"not null;autoCreateTime"`
FinishedAt *time.Time
type Target struct {
ID uint `gorm:"primaryKey"`
RepositoryID uint `gorm:"not null;index:idx_targets_repo_name,unique"`
Name string `gorm:"not null;index:idx_targets_repo_name,unique"`
Type string `gorm:"not null"`
Tags json.RawMessage `gorm:"type:jsonb;default:'{}'"`
Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"`
CreatedAt time.Time
UpdatedAt time.Time
Repository Repository `gorm:"foreignKey:RepositoryID"`
}
func (Campaign) TableName() string { return "campaigns" }
func (Target) TableName() string { return "targets" }
type Run struct {
ID uint `gorm:"primaryKey"`
TargetID uint `gorm:"not null;index"`
CommitID uint `gorm:"not null;index"`
Status string `gorm:"not null;default:running;index"`
StartedAt time.Time `gorm:"not null;autoCreateTime"`
FinishedAt *time.Time
Tags json.RawMessage `gorm:"type:jsonb;default:'{}'"`
Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"`
CreatedAt time.Time
Target Target `gorm:"foreignKey:TargetID"`
Commit Commit `gorm:"foreignKey:CommitID"`
}
func (Run) TableName() string { return "runs" }
type CorpusEntry struct {
ID uint `gorm:"primaryKey"`
TargetID uint `gorm:"not null;index"`
RunID *uint `gorm:"index"`
BlobKey string `gorm:"not null"`
BlobSize int64 `gorm:"not null"`
Fingerprint *string `gorm:"index"`
CreatedAt time.Time
Target Target `gorm:"foreignKey:TargetID"`
Run *Run `gorm:"foreignKey:RunID"`
}
func (CorpusEntry) TableName() string { return "corpus_entries" }
type CrashSignature struct {
ID uint `gorm:"primaryKey"`

299
internal/models/target.go Normal file
View File

@ -0,0 +1,299 @@
package models
import (
"context"
"encoding/json"
"fmt"
"time"
cairnapi "github.com/mattnite/cairn/internal/api"
"gorm.io/gorm"
)
type CreateTargetParams struct {
RepositoryID uint
Name string
Type string
Tags json.RawMessage
Metadata json.RawMessage
}
func GetOrCreateTarget(ctx context.Context, db *gorm.DB, p CreateTargetParams) (*cairnapi.Target, error) {
if p.Tags == nil {
p.Tags = json.RawMessage("{}")
}
if p.Metadata == nil {
p.Metadata = json.RawMessage("{}")
}
target := &Target{}
err := db.WithContext(ctx).
Where("repository_id = ? AND name = ?", p.RepositoryID, p.Name).
First(target).Error
if err == gorm.ErrRecordNotFound {
target = &Target{
RepositoryID: p.RepositoryID,
Name: p.Name,
Type: p.Type,
Tags: p.Tags,
Metadata: p.Metadata,
}
if err := db.WithContext(ctx).Create(target).Error; err != nil {
return nil, fmt.Errorf("creating target: %w", err)
}
} else if err != nil {
return nil, fmt.Errorf("querying target: %w", err)
}
return enrichTarget(ctx, db, *target)
}
func GetTarget(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Target, error) {
target := &Target{}
if err := db.WithContext(ctx).First(target, id).Error; err != nil {
return nil, fmt.Errorf("getting target: %w", err)
}
return enrichTarget(ctx, db, *target)
}
func ListTargets(ctx context.Context, db *gorm.DB, repoID *uint, limit, offset int) ([]cairnapi.Target, int64, error) {
if limit <= 0 {
limit = 50
}
query := db.WithContext(ctx).Model(&Target{})
if repoID != nil {
query = query.Where("repository_id = ?", *repoID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting targets: %w", err)
}
var dbTargets []Target
if err := query.Order("updated_at DESC").Limit(limit).Offset(offset).Find(&dbTargets).Error; err != nil {
return nil, 0, fmt.Errorf("listing targets: %w", err)
}
targets := make([]cairnapi.Target, 0, len(dbTargets))
for _, m := range dbTargets {
t, err := enrichTarget(ctx, db, m)
if err != nil {
return nil, 0, err
}
targets = append(targets, *t)
}
return targets, total, nil
}
func enrichTarget(ctx context.Context, db *gorm.DB, model Target) (*cairnapi.Target, error) {
repo := &Repository{}
if err := db.WithContext(ctx).First(repo, model.RepositoryID).Error; err != nil {
return nil, fmt.Errorf("loading target repository: %w", err)
}
var runCount int64
if err := db.WithContext(ctx).Model(&Run{}).Where("target_id = ?", model.ID).Count(&runCount).Error; err != nil {
return nil, fmt.Errorf("counting target runs: %w", err)
}
var corpusCount int64
if err := db.WithContext(ctx).Model(&CorpusEntry{}).Where("target_id = ?", model.ID).Count(&corpusCount).Error; err != nil {
return nil, fmt.Errorf("counting target corpus: %w", err)
}
t := targetFromModel(model)
t.RepoName = repo.Name
t.RunCount = runCount
t.CorpusCount = corpusCount
return &t, nil
}
func targetFromModel(m Target) cairnapi.Target {
return cairnapi.Target{
ID: m.ID,
RepositoryID: m.RepositoryID,
Name: m.Name,
Type: m.Type,
Tags: m.Tags,
Metadata: m.Metadata,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
// Run functions
func CreateRun(ctx context.Context, db *gorm.DB, targetID, commitID uint) (*cairnapi.Run, error) {
run := &Run{
TargetID: targetID,
CommitID: commitID,
Status: "running",
StartedAt: time.Now(),
Tags: json.RawMessage("{}"),
Metadata: json.RawMessage("{}"),
}
if err := db.WithContext(ctx).Create(run).Error; err != nil {
return nil, fmt.Errorf("creating run: %w", err)
}
return enrichRun(ctx, db, *run)
}
func FinishRun(ctx context.Context, db *gorm.DB, id uint) error {
now := time.Now()
if err := db.WithContext(ctx).Model(&Run{}).Where("id = ?", id).Updates(map[string]any{
"status": "finished",
"finished_at": now,
}).Error; err != nil {
return fmt.Errorf("finishing run: %w", err)
}
return nil
}
func GetRun(ctx context.Context, db *gorm.DB, id uint) (*cairnapi.Run, error) {
run := &Run{}
if err := db.WithContext(ctx).First(run, id).Error; err != nil {
return nil, fmt.Errorf("getting run: %w", err)
}
return enrichRun(ctx, db, *run)
}
func ListRuns(ctx context.Context, db *gorm.DB, targetID *uint, limit, offset int) ([]cairnapi.Run, int64, error) {
if limit <= 0 {
limit = 50
}
query := db.WithContext(ctx).Model(&Run{})
if targetID != nil {
query = query.Where("target_id = ?", *targetID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting runs: %w", err)
}
var dbRuns []Run
if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&dbRuns).Error; err != nil {
return nil, 0, fmt.Errorf("listing runs: %w", err)
}
runs := make([]cairnapi.Run, 0, len(dbRuns))
for _, m := range dbRuns {
r, err := enrichRun(ctx, db, m)
if err != nil {
return nil, 0, err
}
runs = append(runs, *r)
}
return runs, total, nil
}
func enrichRun(ctx context.Context, db *gorm.DB, model Run) (*cairnapi.Run, error) {
target := &Target{}
if err := db.WithContext(ctx).First(target, model.TargetID).Error; err != nil {
return nil, fmt.Errorf("loading run target: %w", err)
}
repo := &Repository{}
if err := db.WithContext(ctx).First(repo, target.RepositoryID).Error; err != nil {
return nil, fmt.Errorf("loading run repository: %w", err)
}
commit := &Commit{}
if err := db.WithContext(ctx).First(commit, model.CommitID).Error; err != nil {
return nil, fmt.Errorf("loading run commit: %w", err)
}
var artifactCount int64
_ = db.WithContext(ctx).Model(&Artifact{}).Where("run_id = ?", model.ID).Count(&artifactCount).Error
r := runFromModel(model)
r.TargetName = target.Name
r.RepoName = repo.Name
r.CommitSHA = commit.SHA
r.ArtifactCount = artifactCount
return &r, nil
}
func runFromModel(m Run) cairnapi.Run {
return cairnapi.Run{
ID: m.ID,
TargetID: m.TargetID,
CommitID: m.CommitID,
Status: m.Status,
StartedAt: m.StartedAt,
FinishedAt: m.FinishedAt,
Tags: m.Tags,
Metadata: m.Metadata,
CreatedAt: m.CreatedAt,
}
}
// Corpus functions
type CreateCorpusEntryParams struct {
TargetID uint
RunID *uint
BlobKey string
BlobSize int64
Fingerprint *string
}
func CreateCorpusEntry(ctx context.Context, db *gorm.DB, p CreateCorpusEntryParams) (*cairnapi.CorpusEntry, error) {
entry := &CorpusEntry{
TargetID: p.TargetID,
RunID: p.RunID,
BlobKey: p.BlobKey,
BlobSize: p.BlobSize,
Fingerprint: p.Fingerprint,
}
if err := db.WithContext(ctx).Create(entry).Error; err != nil {
return nil, fmt.Errorf("creating corpus entry: %w", err)
}
return corpusEntryToAPI(*entry), nil
}
func ListCorpusEntries(ctx context.Context, db *gorm.DB, targetID uint, limit, offset int) ([]cairnapi.CorpusEntry, int64, error) {
if limit <= 0 {
limit = 1000
}
query := db.WithContext(ctx).Model(&CorpusEntry{}).Where("target_id = ?", targetID)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting corpus entries: %w", err)
}
var dbEntries []CorpusEntry
if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&dbEntries).Error; err != nil {
return nil, 0, fmt.Errorf("listing corpus entries: %w", err)
}
entries := make([]cairnapi.CorpusEntry, 0, len(dbEntries))
for _, e := range dbEntries {
entries = append(entries, *corpusEntryToAPI(e))
}
return entries, total, nil
}
func corpusEntryToAPI(m CorpusEntry) *cairnapi.CorpusEntry {
return &cairnapi.CorpusEntry{
ID: m.ID,
TargetID: m.TargetID,
RunID: m.RunID,
BlobKey: m.BlobKey,
BlobSize: m.BlobSize,
Fingerprint: m.Fingerprint,
CreatedAt: m.CreatedAt,
}
}

View File

@ -35,7 +35,9 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) {
crashGroupAPI := &handler.CrashGroupHandler{DB: cfg.DB}
searchAPI := &handler.SearchHandler{DB: cfg.DB}
regressionAPI := &handler.RegressionHandler{DB: cfg.DB, ForgejoSync: forgejoSync}
campaignAPI := &handler.CampaignHandler{DB: cfg.DB}
targetAPI := &handler.TargetHandler{DB: cfg.DB, Store: cfg.Store}
runAPI := &handler.RunHandler{DB: cfg.DB}
corpusAPI := &handler.CorpusHandler{DB: cfg.DB, Store: cfg.Store}
dashboardAPI := &handler.DashboardHandler{DB: cfg.DB}
webhookH := &handler.WebhookHandler{Sync: forgejoSync, Secret: cfg.WebhookSecret}
@ -57,8 +59,9 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) {
r.GET("/repos", pages.Repos)
r.GET("/crashgroups", pages.CrashGroups)
r.GET("/crashgroups/:id", pages.CrashGroupDetail)
r.GET("/campaigns", pages.Campaigns)
r.GET("/campaigns/:id", pages.CampaignDetail)
r.GET("/targets", pages.Targets)
r.GET("/targets/:id", pages.TargetDetail)
r.GET("/runs/:id", pages.RunDetail)
r.GET("/search", pages.Search)
r.GET("/regression", pages.Regression)
@ -72,10 +75,16 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) {
api.GET("/crashgroups/:id", crashGroupAPI.Detail)
api.GET("/search", searchAPI.Search)
api.POST("/regression/check", regressionAPI.Check)
api.POST("/campaigns", campaignAPI.Create)
api.GET("/campaigns", campaignAPI.List)
api.GET("/campaigns/:id", campaignAPI.Detail)
api.POST("/campaigns/:id/finish", campaignAPI.Finish)
api.POST("/targets", targetAPI.Ensure)
api.GET("/targets", targetAPI.List)
api.GET("/targets/:id", targetAPI.Detail)
api.POST("/targets/:id/corpus", corpusAPI.Upload)
api.GET("/targets/:id/corpus", corpusAPI.List)
api.GET("/targets/:id/corpus/:entry_id/download", corpusAPI.Download)
api.POST("/runs", runAPI.Start)
api.GET("/runs", runAPI.List)
api.GET("/runs/:id", runAPI.Detail)
api.POST("/runs/:id/finish", runAPI.Finish)
api.GET("/dashboard", dashboardAPI.Stats)
// Webhooks

View File

@ -81,8 +81,9 @@ func LoadTemplates() (*Templates, error) {
"templates/pages/crashgroup_detail.html",
"templates/pages/search.html",
"templates/pages/regression.html",
"templates/pages/campaigns.html",
"templates/pages/campaign_detail.html",
"templates/pages/targets.html",
"templates/pages/target_detail.html",
"templates/pages/run_detail.html",
}
pages := map[string]*template.Template{}

View File

@ -274,7 +274,7 @@ func (h *PageHandler) Regression(c *gin.Context) {
_ = h.Templates.Render(c.Writer, "regression", data)
}
func (h *PageHandler) Campaigns(c *gin.Context) {
func (h *PageHandler) Targets(c *gin.Context) {
limit, _ := strconv.Atoi(c.Query("limit"))
offset, _ := strconv.Atoi(c.Query("offset"))
if limit <= 0 {
@ -287,51 +287,81 @@ func (h *PageHandler) Campaigns(c *gin.Context) {
return
}
campaigns, total, err := models.ListCampaigns(c.Request.Context(), h.DB, repoID, limit, offset)
targets, total, err := models.ListTargets(c.Request.Context(), h.DB, repoID, limit, offset)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
data := PageData{
Title: "Campaigns",
Title: "Targets",
Content: map[string]any{
"Campaigns": campaigns,
"Total": int(total),
"Targets": targets,
"Total": int(total),
},
}
c.Header("Content-Type", "text/html; charset=utf-8")
_ = h.Templates.Render(c.Writer, "campaigns", data)
_ = h.Templates.Render(c.Writer, "targets", data)
}
func (h *PageHandler) CampaignDetail(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "campaign id")
func (h *PageHandler) TargetDetail(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "target id")
if err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
campaign, err := models.GetCampaign(c.Request.Context(), h.DB, id)
target, err := models.GetTarget(c.Request.Context(), h.DB, id)
if err != nil {
c.String(http.StatusNotFound, "campaign not found")
c.String(http.StatusNotFound, "target not found")
return
}
campaignID := campaign.ID
targetID := target.ID
runs, _, _ := models.ListRuns(c.Request.Context(), h.DB, &targetID, 50, 0)
corpus, corpusTotal, _ := models.ListCorpusEntries(c.Request.Context(), h.DB, targetID, 50, 0)
data := PageData{
Title: "Target: " + target.Name,
Content: map[string]any{
"Target": target,
"Runs": runs,
"Corpus": corpus,
"CorpusTotal": corpusTotal,
},
}
c.Header("Content-Type", "text/html; charset=utf-8")
_ = h.Templates.Render(c.Writer, "target_detail", data)
}
func (h *PageHandler) RunDetail(c *gin.Context) {
id, err := parseUintID(c.Param("id"), "run id")
if err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
run, err := models.GetRun(c.Request.Context(), h.DB, id)
if err != nil {
c.String(http.StatusNotFound, "run not found")
return
}
runID := run.ID
artifacts, _, _ := models.ListArtifacts(c.Request.Context(), h.DB, models.ListArtifactsParams{
CampaignID: &campaignID,
Limit: 50,
RunID: &runID,
Limit: 50,
})
data := PageData{
Title: "Campaign: " + campaign.Name,
Title: fmt.Sprintf("Run #%d", run.ID),
Content: map[string]any{
"Campaign": campaign,
"Run": run,
"Artifacts": artifacts,
},
}
c.Header("Content-Type", "text/html; charset=utf-8")
_ = h.Templates.Render(c.Writer, "campaign_detail", data)
_ = h.Templates.Render(c.Writer, "run_detail", data)
}
func parseUintID(raw string, field string) (uint, error) {

View File

@ -404,18 +404,23 @@ code {
border-color: rgba(61, 122, 74, 0.3);
}
/* ======= CAMPAIGN BADGES ======= */
.badge-campaign-running {
/* ======= RUN BADGES ======= */
.badge-run-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 {
.badge-run-finished {
background: rgba(61, 122, 74, 0.1);
color: var(--success);
border-color: rgba(61, 122, 74, 0.3);
}
.badge-run-failed {
background: rgba(255, 34, 51, 0.1);
color: var(--danger);
border-color: rgba(255, 34, 51, 0.3);
}
/* ======= SEARCH ======= */
.search-form {

View File

@ -16,7 +16,7 @@
<li><a href="/"><span class="nav-icon">&#5855;</span> Dashboard</a></li>
<li><a href="/artifacts"><span class="nav-icon">&#5833;</span> Artifacts</a></li>
<li><a href="/crashgroups"><span class="nav-icon">&#5854;</span> Crash Groups</a></li>
<li><a href="/campaigns"><span class="nav-icon">&#5839;</span> Campaigns</a></li>
<li><a href="/targets"><span class="nav-icon">&#5839;</span> Targets</a></li>
<hr class="sidebar-divider">
<li><a href="/repos"><span class="nav-icon">&#5798;</span> Repositories</a></li>
<li><a href="/regression"><span class="nav-icon">&#5834;</span> Regression</a></li>

View File

@ -1,38 +0,0 @@
{{define "content"}}
<div class="campaigns-page">
<div class="toolbar">
<span class="result-count">{{.Total}} campaigns</span>
</div>
{{if .Campaigns}}
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>Type</th>
<th>Repository</th>
<th>Artifacts</th>
<th>Started</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Campaigns}}
<tr>
<td><span class="badge badge-campaign-{{.Status}}">{{.Status}}</span></td>
<td>{{.Name}}</td>
<td>{{.Type}}</td>
<td>{{.RepoName}}</td>
<td>{{.ArtifactCount}}</td>
<td>{{timeAgo .StartedAt}}</td>
<td><a href="/campaigns/{{.ID}}" class="btn btn-sm">View</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No campaigns yet. Use <code>cairn campaign start</code> to begin a campaign.</p>
{{end}}
</div>
{{end}}

View File

@ -1,29 +1,31 @@
{{define "content"}}
<div class="campaign-detail">
<div class="run-detail">
<div class="detail-header">
<span class="badge badge-campaign-{{.Campaign.Status}}">{{.Campaign.Status}}</span>
<span class="detail-repo">{{.Campaign.RepoName}}</span>
<span class="badge badge-run-{{.Run.Status}}">{{.Run.Status}}</span>
<span class="detail-repo">{{.Run.RepoName}} / {{.Run.TargetName}}</span>
</div>
<h3>{{.Campaign.Name}}</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Type</label>
<span>{{.Campaign.Type}}</span>
<label>Target</label>
<span>{{.Run.TargetName}}</span>
</div>
<div class="detail-item">
<label>Commit</label>
<span><code>{{shortSHA .Run.CommitSHA}}</code></span>
</div>
<div class="detail-item">
<label>Artifacts</label>
<span>{{.Campaign.ArtifactCount}}</span>
<span>{{.Run.ArtifactCount}}</span>
</div>
<div class="detail-item">
<label>Started</label>
<span>{{timeAgo .Campaign.StartedAt}}</span>
<span>{{timeAgo .Run.StartedAt}}</span>
</div>
{{if .Campaign.FinishedAt}}
{{if .Run.FinishedAt}}
<div class="detail-item">
<label>Finished</label>
<span>{{timeAgo (derefTime .Campaign.FinishedAt)}}</span>
<span>{{timeAgo (derefTime .Run.FinishedAt)}}</span>
</div>
{{end}}
</div>
@ -56,7 +58,7 @@
</tbody>
</table>
{{else}}
<p class="empty-state">No artifacts in this campaign yet.</p>
<p class="empty-state">No artifacts in this run.</p>
{{end}}
</section>
</div>

View File

@ -0,0 +1,87 @@
{{define "content"}}
<div class="target-detail">
<div class="detail-header">
<span class="badge badge-{{.Target.Type}}">{{.Target.Type}}</span>
<span class="detail-repo">{{.Target.RepoName}}</span>
</div>
<h3>{{.Target.Name}}</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Type</label>
<span>{{.Target.Type}}</span>
</div>
<div class="detail-item">
<label>Runs</label>
<span>{{.Target.RunCount}}</span>
</div>
<div class="detail-item">
<label>Corpus Entries</label>
<span>{{.Target.CorpusCount}}</span>
</div>
<div class="detail-item">
<label>Created</label>
<span>{{timeAgo .Target.CreatedAt}}</span>
</div>
</div>
<section class="section">
<h3>Runs</h3>
{{if .Runs}}
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Commit</th>
<th>Artifacts</th>
<th>Started</th>
<th>Finished</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Runs}}
<tr>
<td><span class="badge badge-run-{{.Status}}">{{.Status}}</span></td>
<td><code>{{shortSHA .CommitSHA}}</code></td>
<td>{{.ArtifactCount}}</td>
<td>{{timeAgo .StartedAt}}</td>
<td>{{if .FinishedAt}}{{timeAgo (derefTime .FinishedAt)}}{{else}}-{{end}}</td>
<td><a href="/runs/{{.ID}}" class="btn btn-sm">View</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No runs yet.</p>
{{end}}
</section>
<section class="section">
<h3>Corpus ({{.CorpusTotal}} entries)</h3>
{{if .Corpus}}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Size</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{{range .Corpus}}
<tr>
<td>#{{.ID}}</td>
<td>{{formatSize .BlobSize}}</td>
<td>{{timeAgo .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No corpus entries yet.</p>
{{end}}
</section>
</div>
{{end}}

View File

@ -0,0 +1,38 @@
{{define "content"}}
<div class="targets-page">
<div class="toolbar">
<span class="result-count">{{.Total}} targets</span>
</div>
{{if .Targets}}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Repository</th>
<th>Runs</th>
<th>Corpus</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Targets}}
<tr>
<td>{{.Name}}</td>
<td><span class="badge badge-{{.Type}}">{{.Type}}</span></td>
<td>{{.RepoName}}</td>
<td>{{.RunCount}}</td>
<td>{{.CorpusCount}}</td>
<td>{{timeAgo .UpdatedAt}}</td>
<td><a href="/targets/{{.ID}}" class="btn btn-sm">View</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No targets yet. Use <code>cairn target ensure</code> to register a target.</p>
{{end}}
</div>
{{end}}