Break campaign into targets and runs
This commit is contained in:
parent
90d61f1a33
commit
edff8885f0
|
|
@ -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}"
|
||||
cairn corpus upload \
|
||||
-server "${CAIRN_SERVER}" \
|
||||
-target-id "${CAIRN_TARGET_ID}" \
|
||||
-run-id "${RUN_ID}" \
|
||||
-dir "${QUEUE_DIR}"
|
||||
fi
|
||||
fi
|
||||
|
||||
cairn upload "$@"
|
||||
rm -f "corpus-${TARGET_NUM}.tar.gz"
|
||||
fi
|
||||
fi
|
||||
# ── Finish run ──
|
||||
finish_run
|
||||
trap - EXIT
|
||||
|
||||
done <<EOF
|
||||
${ZIG_BUILD_ARGS}
|
||||
${TARGETS}
|
||||
EOF
|
||||
|
||||
# ── Final report ──
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,11 +44,25 @@ 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"`
|
||||
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"`
|
||||
|
|
@ -56,10 +70,22 @@ type Campaign struct {
|
|||
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"`
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,17 +64,31 @@ 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 {
|
||||
type Target struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
RepositoryID uint `gorm:"not null;index"`
|
||||
Name string `gorm:"not null"`
|
||||
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 (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
|
||||
|
|
@ -82,10 +96,26 @@ type Campaign struct {
|
|||
Metadata json.RawMessage `gorm:"type:jsonb;default:'{}'"`
|
||||
CreatedAt time.Time
|
||||
|
||||
Repository Repository `gorm:"foreignKey:RepositoryID"`
|
||||
Target Target `gorm:"foreignKey:TargetID"`
|
||||
Commit Commit `gorm:"foreignKey:CommitID"`
|
||||
}
|
||||
|
||||
func (Campaign) TableName() string { return "campaigns" }
|
||||
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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"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,
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<li><a href="/"><span class="nav-icon">ᛟ</span> Dashboard</a></li>
|
||||
<li><a href="/artifacts"><span class="nav-icon">ᛉ</span> Artifacts</a></li>
|
||||
<li><a href="/crashgroups"><span class="nav-icon">ᛞ</span> Crash Groups</a></li>
|
||||
<li><a href="/campaigns"><span class="nav-icon">ᛏ</span> Campaigns</a></li>
|
||||
<li><a href="/targets"><span class="nav-icon">ᛏ</span> Targets</a></li>
|
||||
<hr class="sidebar-divider">
|
||||
<li><a href="/repos"><span class="nav-icon">ᚦ</span> Repositories</a></li>
|
||||
<li><a href="/regression"><span class="nav-icon">ᛊ</span> Regression</a></li>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
Loading…
Reference in New Issue