wavework 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/bin/wavework.mjs +7 -0
- package/examples/shipir.wavework.json +54 -0
- package/package.json +35 -4
- package/src/cli/args.mjs +28 -0
- package/src/cli/dashboard.mjs +41 -0
- package/src/cli/diff-stat.mjs +41 -0
- package/src/cli/hooks.mjs +109 -0
- package/src/cli/init.mjs +104 -0
- package/src/cli/safe-path.mjs +63 -0
- package/src/cli/sync.mjs +302 -0
- package/src/cli/update.mjs +24 -0
- package/src/cli.mjs +78 -0
- package/src/config.mjs +313 -0
- package/src/context.mjs +133 -0
- package/src/render.mjs +148 -0
- package/src/targets.mjs +100 -0
- package/templates/agents/implementer.md +16 -0
- package/templates/dashboard/README.md +96 -0
- package/templates/dashboard/hooks/session-start.mjs +305 -0
- package/templates/dashboard/hooks/subagent-stop.mjs +129 -0
- package/templates/dashboard/lib/ci.mjs +52 -0
- package/templates/dashboard/lib/derive.mjs +105 -0
- package/templates/dashboard/lib/settings.mjs +34 -0
- package/templates/dashboard/lib/tokens.mjs +21 -0
- package/templates/dashboard/pricing.json +11 -0
- package/templates/dashboard/public/app.css +134 -0
- package/templates/dashboard/public/app.js +260 -0
- package/templates/dashboard/public/default-theme.css +86 -0
- package/templates/dashboard/public/format.mjs +138 -0
- package/templates/dashboard/public/index.html +20 -0
- package/templates/dashboard/record.mjs +144 -0
- package/templates/dashboard/server.mjs +254 -0
- package/templates/dashboard-config.json +1 -0
- package/templates/scripts/red-green-check.sh +287 -0
- package/templates/scripts/red-green-check.test.sh +151 -0
- package/templates/settings/hooks.json +31 -0
- package/templates/skills/next-task/SKILL.md +77 -0
- package/templates/skills/next-task-batch/SKILL.md +27 -0
- package/templates/skills/next-task-beads/SKILL.md +29 -0
- package/templates/skills/next-task-branch/SKILL.md +19 -0
- package/templates/skills/next-task-code/SKILL.md +22 -0
- package/templates/skills/next-task-deps/SKILL.md +31 -0
- package/templates/skills/next-task-e2e/SKILL.md +35 -0
- package/templates/skills/next-task-findings/SKILL.md +65 -0
- package/templates/skills/next-task-finish/SKILL.md +43 -0
- package/templates/skills/next-task-implement/SKILL.md +34 -0
- package/templates/skills/next-task-plan/SKILL.md +52 -0
- package/templates/skills/next-task-review/SKILL.md +61 -0
- package/templates/skills/next-task-test/SKILL.md +30 -0
- package/templates/skills/next-task-write-tests/SKILL.md +34 -0
- package/templates/skills/text-to-task/SKILL.md +78 -0
- package/templates/skills/watch-ci/SKILL.md +42 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// CI status for a pushed SHA via `gh run list --commit <sha>`, cached per SHA (only success is final: failed runs get re-run).
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
const SHA = /^[0-9a-f]{7,40}$/i;
|
|
5
|
+
const BAD = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'action_required', 'stale']);
|
|
6
|
+
|
|
7
|
+
export function isSha(v) { return typeof v === 'string' && SHA.test(v); }
|
|
8
|
+
|
|
9
|
+
/** Default gh runner: execFile with an argument list, no shell. Rejects when gh is missing or fails. */
|
|
10
|
+
export function defaultGhRunner(args) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
execFile('gh', args, { timeout: 10_000, maxBuffer: 1024 * 1024 }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function summarize(runs) {
|
|
17
|
+
if (runs.length === 0) return 'none';
|
|
18
|
+
if (runs.some((r) => r.status !== 'completed')) return 'pending';
|
|
19
|
+
if (runs.some((r) => BAD.has(r.conclusion))) return 'failure';
|
|
20
|
+
return 'success';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createCiLookup({ ghRunner = defaultGhRunner, refreshMs = 15_000, now = Date.now } = {}) {
|
|
24
|
+
const cache = new Map(); // sha -> { at, value, inflight }
|
|
25
|
+
|
|
26
|
+
async function fetchSha(sha) {
|
|
27
|
+
try {
|
|
28
|
+
const out = await ghRunner(['run', 'list', '--commit', sha, '--json', 'status,conclusion,url,name']);
|
|
29
|
+
const parsed = JSON.parse(out);
|
|
30
|
+
if (!Array.isArray(parsed)) throw new Error('unexpected gh output');
|
|
31
|
+
const runs = parsed.map((r) => ({
|
|
32
|
+
name: String(r.name ?? ''), status: String(r.status ?? ''), conclusion: r.conclusion ? String(r.conclusion) : null, url: String(r.url ?? ''),
|
|
33
|
+
}));
|
|
34
|
+
return { sha, state: summarize(runs), runs };
|
|
35
|
+
} catch {
|
|
36
|
+
return { sha, state: 'unknown', runs: [] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns { sha, state, runs }; state is unknown when gh is missing/fails or the sha is not a plain hex string. */
|
|
41
|
+
async function lookup(sha) {
|
|
42
|
+
if (!isSha(sha)) return { sha: sha ?? null, state: 'unknown', runs: [] };
|
|
43
|
+
const hit = cache.get(sha);
|
|
44
|
+
const final = hit?.value && (hit.value.state === 'success');
|
|
45
|
+
if (hit?.inflight) return hit.inflight;
|
|
46
|
+
if (hit && (final || now() - hit.at < refreshMs)) return hit.value;
|
|
47
|
+
const inflight = fetchSha(sha).then((value) => { cache.set(sha, { at: now(), value }); return value; });
|
|
48
|
+
cache.set(sha, { ...(hit ?? { at: 0 }), inflight });
|
|
49
|
+
return inflight;
|
|
50
|
+
}
|
|
51
|
+
return { lookup };
|
|
52
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Derives the run timeline (steps, children, reviews, tests, totals) from a run's events.
|
|
2
|
+
const AGENT_SUMS = ['durationMs', 'tokensIn', 'tokensOut', 'tokensCacheRead', 'tokensCacheWrite', 'cost'];
|
|
3
|
+
|
|
4
|
+
const ms = (a, b) => {
|
|
5
|
+
const d = Date.parse(b) - Date.parse(a);
|
|
6
|
+
return Number.isFinite(d) ? d : null;
|
|
7
|
+
};
|
|
8
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sums agent stats. `cost` is null (never 0 or a partial sum) when there are no agents or any agent
|
|
12
|
+
* lacks a numeric cost (unknown model): an unknown cost must not look like a cheap one.
|
|
13
|
+
*/
|
|
14
|
+
function sumAgents(agents) {
|
|
15
|
+
const out = { count: agents.length };
|
|
16
|
+
for (const k of AGENT_SUMS) out[k] = agents.reduce((s, a) => s + num(a[k]), 0);
|
|
17
|
+
const known = agents.length > 0 && agents.every((a) => typeof a.cost === 'number' && Number.isFinite(a.cost));
|
|
18
|
+
// Round away binary float noise (0.1 + 0.2).
|
|
19
|
+
out.cost = known ? Math.round(out.cost * 1e6) / 1e6 : null;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function deriveTimeline(run, events, now = Date.now()) {
|
|
24
|
+
const steps = [];
|
|
25
|
+
const open = new Map(); // step + wave -> stack of open step entries
|
|
26
|
+
const key = (step, wave) => `${step}\0${wave ?? ''}`;
|
|
27
|
+
const sha = (e) => (e.sha ? { sha: e.sha } : {});
|
|
28
|
+
for (const e of events) {
|
|
29
|
+
if (e.type === 'step-start') {
|
|
30
|
+
const entry = { step: e.step, wave: e.wave ?? null, start: e.ts, end: null, durationMs: null };
|
|
31
|
+
steps.push(entry);
|
|
32
|
+
const k = key(e.step, e.wave);
|
|
33
|
+
if (!open.has(k)) open.set(k, []);
|
|
34
|
+
open.get(k).push(entry);
|
|
35
|
+
} else if (e.type === 'step-end') {
|
|
36
|
+
let entry = open.get(key(e.step, e.wave))?.pop();
|
|
37
|
+
if (!entry && e.wave == null) {
|
|
38
|
+
// End without a wave: close the latest open entry of that step.
|
|
39
|
+
const stacks = [...open.entries()].filter(([k, v]) => k.startsWith(`${e.step}\0`) && v.length);
|
|
40
|
+
entry = stacks.length ? stacks[stacks.length - 1][1].pop() : undefined;
|
|
41
|
+
}
|
|
42
|
+
if (entry) {
|
|
43
|
+
entry.end = e.ts;
|
|
44
|
+
entry.durationMs = ms(entry.start, e.ts);
|
|
45
|
+
Object.assign(entry, sha(e));
|
|
46
|
+
} else {
|
|
47
|
+
steps.push({ step: e.step, wave: e.wave ?? null, start: null, end: e.ts, durationMs: null, ...sha(e) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const children = [];
|
|
53
|
+
const byBead = new Map();
|
|
54
|
+
for (const e of events) {
|
|
55
|
+
if (e.type === 'child-start') {
|
|
56
|
+
const c = {
|
|
57
|
+
bead: e.bead, model: e.model ?? null, wave: e.wave ?? null, start: e.ts, end: null,
|
|
58
|
+
durationMs: null, result: null, redBeforeGreen: null,
|
|
59
|
+
};
|
|
60
|
+
children.push(c);
|
|
61
|
+
byBead.set(e.bead, c);
|
|
62
|
+
} else if (e.type === 'child-end') {
|
|
63
|
+
let c = byBead.get(e.bead);
|
|
64
|
+
if (!c) {
|
|
65
|
+
c = { bead: e.bead, model: e.model ?? null, wave: e.wave ?? null, start: null, end: null, durationMs: null, result: null, redBeforeGreen: null };
|
|
66
|
+
children.push(c);
|
|
67
|
+
byBead.set(e.bead, c);
|
|
68
|
+
}
|
|
69
|
+
c.end = e.ts;
|
|
70
|
+
c.durationMs = c.start ? ms(c.start, e.ts) : null;
|
|
71
|
+
c.result = e.result ?? null;
|
|
72
|
+
c.redBeforeGreen = e.redBeforeGreen ?? null;
|
|
73
|
+
if (!c.model && e.model) c.model = e.model;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const agentEvents = events.filter((e) => e.type === 'agent');
|
|
78
|
+
const perChild = new Map(children.map((c) => [c, []]));
|
|
79
|
+
for (const a of agentEvents) {
|
|
80
|
+
let owner = a.bead ? byBead.get(a.bead) : undefined;
|
|
81
|
+
if (!owner) {
|
|
82
|
+
// No bead on the event: attribute to the child whose window contains it.
|
|
83
|
+
owner = children.find((c) => c.start && a.ts >= c.start && (!c.end || a.ts <= c.end));
|
|
84
|
+
}
|
|
85
|
+
if (owner) perChild.get(owner).push(a);
|
|
86
|
+
}
|
|
87
|
+
for (const c of children) c.agents = sumAgents(perChild.get(c));
|
|
88
|
+
|
|
89
|
+
const reviews = events.filter((e) => e.type === 'review')
|
|
90
|
+
.map((e) => ({ ts: e.ts, round: e.round ?? null, verdict: e.verdict ?? null, mustFix: e.mustFix ?? 0 }));
|
|
91
|
+
const tests = events.filter((e) => e.type === 'tests')
|
|
92
|
+
.map((e) => ({ ts: e.ts, scope: e.scope ?? null, passed: e.passed ?? 0, failed: e.failed ?? 0, duration: e.duration ?? null }));
|
|
93
|
+
|
|
94
|
+
const all = sumAgents(agentEvents);
|
|
95
|
+
const end = run?.ended ? Date.parse(run.ended) : now;
|
|
96
|
+
const start = run?.started ? Date.parse(run.started) : NaN;
|
|
97
|
+
const totals = {
|
|
98
|
+
agents: all.count, tokensIn: all.tokensIn, tokensOut: all.tokensOut,
|
|
99
|
+
tokensCacheRead: all.tokensCacheRead, tokensCacheWrite: all.tokensCacheWrite, cost: all.cost,
|
|
100
|
+
durationMs: Number.isFinite(start) && Number.isFinite(end) ? end - start : null,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const pushed = [...steps].reverse().find((s) => s.step === 'push' && s.sha)?.sha ?? null;
|
|
104
|
+
return { steps, children, reviews, tests, totals, sha: pushed };
|
|
105
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Dashboard settings written by `wavework init/update` into ../config.json: { port, tokensFile }.
|
|
2
|
+
// The runtime never reads .claude/wavework.json, so it works in any checkout that has this folder.
|
|
3
|
+
// Without config.json (or with an unreadable one) the defaults apply.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_PORT = 4690;
|
|
9
|
+
|
|
10
|
+
/** `{ port, tokensFile }` from `<dashboardDir>/config.json`; defaults for anything missing or invalid. */
|
|
11
|
+
export function readSettings(dashboardDir) {
|
|
12
|
+
let raw = {};
|
|
13
|
+
try { raw = JSON.parse(readFileSync(path.join(dashboardDir, 'config.json'), 'utf8')); } catch { /* defaults */ }
|
|
14
|
+
const port = Number.isInteger(raw?.port) && raw.port >= 1024 && raw.port <= 65535 ? raw.port : DEFAULT_PORT;
|
|
15
|
+
const tokensFile = typeof raw?.tokensFile === 'string' && raw.tokensFile !== '' ? raw.tokensFile : null;
|
|
16
|
+
return { port, tokensFile };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** PIPELINE_DASHBOARD_PORT wins over config.json. Port 0 (ephemeral) is only meant for tests. */
|
|
20
|
+
export function resolvePort(dashboardDir, env = process.env) {
|
|
21
|
+
if (env.PIPELINE_DASHBOARD_PORT !== undefined) return Number(env.PIPELINE_DASHBOARD_PORT);
|
|
22
|
+
return readSettings(dashboardDir).port;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Repo directory name for the UI title: the main checkout (parent of the git common dir), else the checkout root. */
|
|
26
|
+
export function projectName(root) {
|
|
27
|
+
try {
|
|
28
|
+
const common = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
29
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
}).trim();
|
|
31
|
+
if (common) return path.basename(path.dirname(common));
|
|
32
|
+
} catch { /* not a git checkout */ }
|
|
33
|
+
return path.basename(path.resolve(root));
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Safe lookup of the project's own tokens CSS (config `dashboard.tokensFile`).
|
|
2
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Absolute real path of `rel` inside `root`, or null. Refuses anything that is not a plain repo-relative
|
|
7
|
+
* path (absolute, `..`, `.` or empty segments, backslash, NUL) and anything whose real path (symlinks
|
|
8
|
+
* resolved, also in parent directories) leaves the real repo root or is not a regular file.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveTokensFile(root, rel) {
|
|
11
|
+
if (typeof rel !== 'string' || rel === '' || rel.includes('\0') || rel.includes('\\')) return null;
|
|
12
|
+
if (!rel.endsWith('.css')) return null; // config.json can be edited by hand: the suffix is enforced here too
|
|
13
|
+
if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) return null;
|
|
14
|
+
if (rel.split('/').some((s) => s === '' || s === '.' || s === '..')) return null;
|
|
15
|
+
try {
|
|
16
|
+
const realRoot = realpathSync(root);
|
|
17
|
+
const real = realpathSync(path.join(realRoot, ...rel.split('/')));
|
|
18
|
+
if (!real.startsWith(realRoot + path.sep) || !statSync(real).isFile()) return null;
|
|
19
|
+
return real;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"unit": "USD per million tokens",
|
|
3
|
+
"source": "claude-api skill model table (cached 2026-10-06) and prompt-caching rules: cache read as listed (Haiku 5.5: 0.1x input), 5-minute cache write 1.25x input",
|
|
4
|
+
"note": "claude-haiku-5-5 uses the rate card for prompts up to 100K tokens. A model missing here gets no cost.",
|
|
5
|
+
"models": {
|
|
6
|
+
"claude-opus-5-5": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 },
|
|
7
|
+
"claude-sonnet-5-5": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5 },
|
|
8
|
+
"claude-haiku-5-5": { "input": 0.1, "output": 0.5, "cacheRead": 0.01, "cacheWrite": 0.125 },
|
|
9
|
+
"claude-fable-5-1": { "input": 10, "output": 50, "cacheRead": 0.25, "cacheWrite": 12.5 }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/* Pipeline dashboard. Tokens only (/default-theme.css, optionally overridden by the project tokens at /tokens.css); no raw colors, sizes, radii or durations. */
|
|
2
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
3
|
+
|
|
4
|
+
html { background: var(--color-bg); }
|
|
5
|
+
body {
|
|
6
|
+
margin: 0;
|
|
7
|
+
background: var(--color-bg);
|
|
8
|
+
color: var(--color-text);
|
|
9
|
+
font-family: var(--font-sans);
|
|
10
|
+
font-size: var(--text-body-size);
|
|
11
|
+
line-height: var(--text-body-leading);
|
|
12
|
+
letter-spacing: var(--text-body-tracking);
|
|
13
|
+
-webkit-font-smoothing: antialiased;
|
|
14
|
+
padding-left: var(--safe-left);
|
|
15
|
+
padding-right: var(--safe-right);
|
|
16
|
+
padding-bottom: var(--safe-bottom);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
a { color: var(--color-accent-text); text-decoration: none; }
|
|
20
|
+
a:hover { text-decoration: underline; }
|
|
21
|
+
:focus-visible { outline: var(--border-width-thick) solid var(--color-focus); outline-offset: var(--focus-offset); border-radius: var(--radius-sm); }
|
|
22
|
+
|
|
23
|
+
.topbar {
|
|
24
|
+
position: sticky; top: 0; z-index: var(--z-sticky);
|
|
25
|
+
display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
|
|
26
|
+
min-height: var(--shell-bar-height);
|
|
27
|
+
padding: 0 var(--space-5);
|
|
28
|
+
background: var(--color-material);
|
|
29
|
+
-webkit-backdrop-filter: var(--blur-material);
|
|
30
|
+
backdrop-filter: var(--blur-material);
|
|
31
|
+
border-bottom: var(--border-width) solid var(--color-border);
|
|
32
|
+
}
|
|
33
|
+
.brand { font-size: var(--text-title-size); font-weight: var(--weight-semibold); letter-spacing: var(--text-title-tracking); color: var(--color-text); }
|
|
34
|
+
.brand:hover { text-decoration: none; }
|
|
35
|
+
|
|
36
|
+
.live { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-small-size); color: var(--color-text-muted); }
|
|
37
|
+
.live::before { content: ''; width: var(--size-dot); height: var(--size-dot); border-radius: var(--radius-pill); background: var(--color-text-subtle); }
|
|
38
|
+
.live[data-state='live']::before { background: var(--color-ok-solid); }
|
|
39
|
+
.live[data-state='offline']::before { background: var(--color-warn-solid); }
|
|
40
|
+
|
|
41
|
+
.page { max-width: var(--page-width-wide); margin: 0 auto; padding: var(--space-6) var(--space-5) var(--space-8); display: grid; gap: var(--space-6); min-width: 0; }
|
|
42
|
+
|
|
43
|
+
h1, h2, h3, p { margin: 0; overflow-wrap: break-word; min-width: 0; }
|
|
44
|
+
h1 { font-size: var(--text-heading-size); line-height: var(--text-heading-leading); letter-spacing: var(--text-heading-tracking); font-weight: var(--weight-semibold); }
|
|
45
|
+
h2 { font-size: var(--text-title-size); line-height: var(--text-title-leading); letter-spacing: var(--text-title-tracking); font-weight: var(--weight-semibold); }
|
|
46
|
+
.muted { color: var(--color-text-muted); }
|
|
47
|
+
.subtle { color: var(--color-text-subtle); }
|
|
48
|
+
.small { font-size: var(--text-small-size); line-height: var(--text-small-leading); }
|
|
49
|
+
.mono { font-family: var(--font-mono); font-size: var(--text-small-size); overflow-wrap: anywhere; }
|
|
50
|
+
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
51
|
+
|
|
52
|
+
.head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3) var(--space-5); }
|
|
53
|
+
.back { display: inline-flex; align-items: center; min-height: var(--control-height); padding: 0 var(--space-3); margin-left: calc(var(--space-3) * -1); border-radius: var(--radius-sm); transition: background-color var(--duration-fast) var(--ease-out), transform var(--duration-press) var(--ease-out); }
|
|
54
|
+
.back:hover { background: var(--color-surface-hover); text-decoration: none; }
|
|
55
|
+
.back:active { transform: scale(var(--press-scale)); }
|
|
56
|
+
|
|
57
|
+
.card { container-type: inline-size; background: var(--color-surface); border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: var(--space-5); display: grid; gap: var(--space-4); align-content: start; min-width: 0; }
|
|
58
|
+
.card > h2 { margin-bottom: calc(var(--space-1) * -1); }
|
|
59
|
+
.stack { display: grid; gap: var(--space-6); min-width: 0; }
|
|
60
|
+
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--content-narrow) / 2), 1fr)); gap: var(--space-5); }
|
|
61
|
+
|
|
62
|
+
/* Badges: soft tint + dot + text, never color only */
|
|
63
|
+
.badge { display: inline-flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-3); border-radius: var(--radius-pill); font-size: var(--text-caption-size); line-height: var(--text-caption-leading); font-weight: var(--weight-medium); white-space: nowrap; background: var(--color-surface-sunken); color: var(--color-text-muted); }
|
|
64
|
+
.badge::before { content: ''; width: var(--size-dot); height: var(--size-dot); border-radius: var(--radius-pill); background: var(--color-text-subtle); }
|
|
65
|
+
.badge[data-tone='ok'] { background: var(--color-ok-soft); color: var(--color-ok); }
|
|
66
|
+
.badge[data-tone='ok']::before { background: var(--color-ok-solid); }
|
|
67
|
+
.badge[data-tone='warn'] { background: var(--color-warn-soft); color: var(--color-warn); }
|
|
68
|
+
.badge[data-tone='warn']::before { background: var(--color-warn-solid); }
|
|
69
|
+
.badge[data-tone='bad'] { background: var(--color-bad-soft); color: var(--color-bad); }
|
|
70
|
+
.badge[data-tone='bad']::before { background: var(--color-bad-solid); }
|
|
71
|
+
.badge[data-tone='info'] { background: var(--color-info-soft); color: var(--color-info); }
|
|
72
|
+
.badge[data-tone='info']::before { background: var(--color-info-solid); }
|
|
73
|
+
.badge[data-tone='info'][data-pulse]::before { animation: pulse var(--duration-pulse) var(--ease-out) infinite; }
|
|
74
|
+
@keyframes pulse { 50% { opacity: var(--opacity-ghost); } }
|
|
75
|
+
|
|
76
|
+
/* Tables: fit the screen (wrap, never scroll sideways), hairline row separators */
|
|
77
|
+
table { width: 100%; border-collapse: collapse; }
|
|
78
|
+
th, td { padding: var(--space-3) var(--space-3); text-align: left; vertical-align: top; overflow-wrap: anywhere; border-bottom: var(--border-width) solid var(--color-border); }
|
|
79
|
+
th { font-size: var(--text-caption-size); letter-spacing: var(--text-caption-tracking); font-weight: var(--weight-medium); color: var(--color-text-muted); }
|
|
80
|
+
tbody tr:last-child td { border-bottom: 0; }
|
|
81
|
+
th.num { text-align: right; }
|
|
82
|
+
.rows tbody tr { transition: background-color var(--duration-fast) var(--ease-out); }
|
|
83
|
+
.rows tbody tr:hover { background: var(--color-surface-hover); }
|
|
84
|
+
.rows .link { display: block; color: inherit; }
|
|
85
|
+
.rows .link:hover { text-decoration: none; }
|
|
86
|
+
tr.nested td:first-child { padding-left: var(--space-6); }
|
|
87
|
+
tr[data-clickable] { cursor: pointer; }
|
|
88
|
+
tr[data-clickable]:active { background: var(--color-surface-sunken); }
|
|
89
|
+
|
|
90
|
+
/* Summary stats in the run header */
|
|
91
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--size-file-col-date)), 1fr)); gap: var(--space-5); }
|
|
92
|
+
.stat { display: grid; gap: var(--space-1); min-width: 0; }
|
|
93
|
+
.stat .label { font-size: var(--text-caption-size); color: var(--color-text-muted); letter-spacing: var(--text-caption-tracking); }
|
|
94
|
+
.stat .value { font-size: var(--text-title-size); font-weight: var(--weight-semibold); letter-spacing: var(--text-title-tracking); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
95
|
+
|
|
96
|
+
/* Step timeline */
|
|
97
|
+
.steps { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
|
|
98
|
+
.steps li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); }
|
|
99
|
+
.steps .dot { width: var(--size-dot); height: var(--size-dot); border-radius: var(--radius-pill); background: var(--color-ok-solid); }
|
|
100
|
+
.steps li[data-open] .dot { background: var(--color-info-solid); }
|
|
101
|
+
|
|
102
|
+
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
|
|
103
|
+
.list li { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3); align-items: center; }
|
|
104
|
+
.link-btn { display: inline-flex; align-items: center; min-height: var(--control-height); padding: 0 var(--space-4); border-radius: var(--radius-sm); background: var(--color-accent-soft); font-weight: var(--weight-medium); transition: background-color var(--duration-fast) var(--ease-out), transform var(--duration-press) var(--ease-out); }
|
|
105
|
+
.link-btn:hover { text-decoration: none; background: var(--color-surface-hover); }
|
|
106
|
+
.link-btn:active { transform: scale(var(--press-scale)); }
|
|
107
|
+
|
|
108
|
+
.state { display: grid; gap: var(--space-3); justify-items: start; padding: var(--space-6); background: var(--color-surface-sunken); border-radius: var(--radius-md); }
|
|
109
|
+
.state[data-tone='bad'] { background: var(--color-bad-soft); color: var(--color-bad); }
|
|
110
|
+
.btn { min-height: var(--control-height); padding: 0 var(--space-5); border: 0; border-radius: var(--radius-sm); background: var(--color-accent); color: var(--color-on-accent); font: inherit; font-weight: var(--weight-medium); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-out), transform var(--duration-press) var(--ease-out); }
|
|
111
|
+
.btn:hover { background: var(--color-accent-hover); }
|
|
112
|
+
.btn:active { transform: scale(var(--press-scale)); }
|
|
113
|
+
|
|
114
|
+
.skeleton { height: var(--space-7); border-radius: var(--radius-md); background: var(--color-surface-sunken); animation: pulse var(--duration-pulse) var(--ease-out) infinite; }
|
|
115
|
+
|
|
116
|
+
/* Narrow cards (phones): tables become stacked rows, header text moves into each cell.
|
|
117
|
+
Tokens have no breakpoint token, so this single documented threshold is the only raw size here. */
|
|
118
|
+
@container (max-width: 40rem) {
|
|
119
|
+
thead { display: none; }
|
|
120
|
+
table, tbody, tr, td { display: block; }
|
|
121
|
+
tr { padding: var(--space-3) 0; border-bottom: var(--border-width) solid var(--color-border); }
|
|
122
|
+
tbody tr:last-child { border-bottom: 0; }
|
|
123
|
+
td { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); border-bottom: 0; padding: var(--space-1) var(--space-3); text-align: right; }
|
|
124
|
+
td::before { content: attr(data-label); color: var(--color-text-muted); font-size: var(--text-caption-size); text-align: left; flex: none; }
|
|
125
|
+
td:first-child { justify-content: flex-start; text-align: left; }
|
|
126
|
+
td:first-child::before { content: none; }
|
|
127
|
+
tr.nested td:first-child { padding-left: var(--space-6); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@media (prefers-reduced-motion: reduce) {
|
|
131
|
+
*, *::before, *::after { animation-duration: var(--duration-spin-calm) !important; transition-duration: var(--duration-press) !important; }
|
|
132
|
+
.badge[data-pulse]::before, .skeleton { animation: none !important; }
|
|
133
|
+
.back:active, .btn:active, .link-btn:active { transform: none; }
|
|
134
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Pipeline dashboard UI. Plain ES modules, no build. All data is rendered with textContent (never innerHTML).
|
|
2
|
+
import {
|
|
3
|
+
ciBadge, findingsOf, formatCost, formatDuration, formatTokens, parseRoute, relativeTime,
|
|
4
|
+
resultBadge, reviewRows, reviewsByBead, safeHref, sortRuns, statusBadge, stepLabel, tokensOf, formatStarted,
|
|
5
|
+
} from './format.mjs';
|
|
6
|
+
|
|
7
|
+
const app = document.getElementById('app');
|
|
8
|
+
|
|
9
|
+
/** Replaces the page content and puts keyboard focus back on the same run link (by data-run) if it had it. */
|
|
10
|
+
function show(...nodes) {
|
|
11
|
+
const run = document.activeElement?.closest?.('[data-run]')?.dataset.run;
|
|
12
|
+
const onBack = document.activeElement?.classList?.contains('back');
|
|
13
|
+
app.replaceChildren(...nodes);
|
|
14
|
+
if (run) app.querySelector(`[data-run="${CSS.escape(run)}"] a.link`)?.focus({ preventScroll: true });
|
|
15
|
+
else if (onBack) app.querySelector('.back')?.focus({ preventScroll: true });
|
|
16
|
+
}
|
|
17
|
+
const liveEl = document.getElementById('live');
|
|
18
|
+
let project = '';
|
|
19
|
+
const appTitle = () => (project ? `Pipeline dashboard · ${project}` : 'Pipeline dashboard');
|
|
20
|
+
// The project name (repo directory) comes from the server; the page works without it.
|
|
21
|
+
fetch('/api/info').then((r) => r.json()).then((info) => {
|
|
22
|
+
if (typeof info?.project !== 'string') return;
|
|
23
|
+
project = info.project;
|
|
24
|
+
const brand = document.querySelector('.brand');
|
|
25
|
+
if (brand) brand.textContent = `Pipeline · ${project}`;
|
|
26
|
+
document.title = appTitle();
|
|
27
|
+
}).catch(() => {});
|
|
28
|
+
|
|
29
|
+
/** Tiny element builder: h('td', {class: 'num'}, 'text', childNode). Strings become text nodes. */
|
|
30
|
+
function h(tag, attrs, ...kids) {
|
|
31
|
+
const el = document.createElement(tag);
|
|
32
|
+
for (const [k, v] of Object.entries(attrs ?? {})) if (v != null && v !== false) el.setAttribute(k, v === true ? '' : v);
|
|
33
|
+
for (const k of kids.flat()) if (k != null && k !== false) el.append(k instanceof Node ? k : document.createTextNode(String(k)));
|
|
34
|
+
return el;
|
|
35
|
+
}
|
|
36
|
+
/** Copies each header text onto its cells (data-label) so CSS can stack rows on narrow cards. */
|
|
37
|
+
function labelCells(tbl) {
|
|
38
|
+
const heads = [...tbl.querySelectorAll('thead th')].map((th) => th.textContent);
|
|
39
|
+
for (const tr of tbl.querySelectorAll('tbody tr')) [...tr.children].forEach((td, i) => td.setAttribute('data-label', heads[i] ?? ''));
|
|
40
|
+
return tbl;
|
|
41
|
+
}
|
|
42
|
+
const badge = (b, pulse) => h('span', { class: 'badge', 'data-tone': b.tone, 'data-pulse': pulse }, b.label);
|
|
43
|
+
const dash = '—';
|
|
44
|
+
const runTitle = (r) => r.branch ?? (r.units?.length ? r.units.join(', ') : r.id);
|
|
45
|
+
|
|
46
|
+
async function getJson(url) {
|
|
47
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
48
|
+
if (!res.ok) throw new Error(res.status === 404 ? 'This run does not exist (any more).' : `Server answered ${res.status}.`);
|
|
49
|
+
return res.json();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const state = { runs: null, listError: null, details: new Map(), ci: new Map(), route: parseRoute(location.hash) };
|
|
53
|
+
|
|
54
|
+
/* ---------- Run list ---------- */
|
|
55
|
+
function liveDuration(run) {
|
|
56
|
+
if (run.status === 'running' && run.started) return Date.now() - Date.parse(run.started);
|
|
57
|
+
return run.totals?.durationMs ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runRow(r, nested) {
|
|
61
|
+
const href = `#/runs/${encodeURIComponent(r.id)}`;
|
|
62
|
+
const link = (kids) => h('a', { class: 'link', href }, kids);
|
|
63
|
+
const ci = ciBadge(state.ci.get(r.id) ?? null);
|
|
64
|
+
const tr = h('tr', { 'data-testid': 'run-row', 'data-run': r.id, class: nested ? 'nested' : null, 'data-clickable': true },
|
|
65
|
+
h('td', null, badge(statusBadge(r.status), r.status === 'running')),
|
|
66
|
+
h('td', null, link([h('strong', null, runTitle(r)), h('div', { class: 'small muted' }, `${r.kind ?? dash} · ${(r.units ?? []).join(', ') || dash}`)])),
|
|
67
|
+
h('td', { class: 'small muted', title: r.started ? relativeTime(r.started) : null }, formatStarted(r.started)),
|
|
68
|
+
h('td', { class: 'num' }, formatDuration(liveDuration(r))),
|
|
69
|
+
h('td', { class: 'num', title: 'Estimated cost' }, formatCost(r.totals?.cost ?? null)),
|
|
70
|
+
h('td', null, ci ? badge(ci) : h('span', { class: 'subtle' }, dash)));
|
|
71
|
+
tr.addEventListener('click', (e) => { if (!e.target.closest('a')) location.hash = href; });
|
|
72
|
+
return tr;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderList() {
|
|
76
|
+
if (state.listError) return errorState(state.listError, loadList);
|
|
77
|
+
if (!state.runs) return show(h('h1', null, 'Pipeline runs'), h('div', { class: 'skeleton' }), h('div', { class: 'skeleton' }));
|
|
78
|
+
const runs = sortRuns(state.runs);
|
|
79
|
+
const body = h('tbody');
|
|
80
|
+
for (const r of runs) {
|
|
81
|
+
body.append(runRow(r, false));
|
|
82
|
+
for (const c of sortRuns(r.children ?? [])) body.append(runRow(c, true));
|
|
83
|
+
}
|
|
84
|
+
const content = runs.length === 0
|
|
85
|
+
? h('div', { class: 'state' }, h('h2', null, 'No runs yet'), h('p', { class: 'muted' }, 'Runs appear here as soon as a /next-task pipeline records its first event.'))
|
|
86
|
+
: h('div', { class: 'card' }, labelCells(h('table', { class: 'rows' },
|
|
87
|
+
h('thead', null, h('tr', null, ['Status', 'Run', 'Started'].map((t) => h('th', null, t)),
|
|
88
|
+
h('th', { class: 'num' }, 'Duration'), h('th', { class: 'num' }, 'Est. cost'), h('th', null, 'CI'))),
|
|
89
|
+
body)));
|
|
90
|
+
show(
|
|
91
|
+
h('div', { class: 'head' }, h('h1', null, 'Pipeline runs'), h('p', { class: 'muted small' }, 'Costs are estimates from token usage.')),
|
|
92
|
+
content);
|
|
93
|
+
document.title = appTitle();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function loadList() {
|
|
97
|
+
try {
|
|
98
|
+
state.runs = await getJson('/api/runs');
|
|
99
|
+
state.listError = null;
|
|
100
|
+
} catch (e) { state.listError = e.message; }
|
|
101
|
+
if (state.route.view === 'list') renderList();
|
|
102
|
+
loadCi();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The list endpoint has no CI info; fetch details of runs with a pushed SHA once, then rely on the server cache. */
|
|
106
|
+
async function loadCi() {
|
|
107
|
+
const flat = (state.runs ?? []).flatMap((r) => [r, ...(r.children ?? [])]);
|
|
108
|
+
await Promise.all(flat.map(async (r) => {
|
|
109
|
+
if (state.ci.has(r.id) && state.ci.get(r.id)?.state === 'success') return;
|
|
110
|
+
try {
|
|
111
|
+
const d = await getJson(`/api/runs/${encodeURIComponent(r.id)}`);
|
|
112
|
+
state.ci.set(r.id, d.ci ?? null);
|
|
113
|
+
} catch { /* leave the badge out */ }
|
|
114
|
+
}));
|
|
115
|
+
if (state.route.view === 'list') renderList();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ---------- Run detail ---------- */
|
|
119
|
+
function stat(label, value, title, live) {
|
|
120
|
+
return h('div', { class: 'stat' }, h('span', { class: 'label' }, label), h('span', { class: 'value', title, 'data-live': live }, value));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function section(title, ...kids) { return h('section', { class: 'card' }, h('h2', null, title), ...kids); }
|
|
124
|
+
const empty = (t) => h('p', { class: 'muted' }, t);
|
|
125
|
+
|
|
126
|
+
function table(heads, rows, testid) {
|
|
127
|
+
return labelCells(h('table', { class: 'rows', 'data-testid': testid },
|
|
128
|
+
h('thead', null, h('tr', null, heads.map(([t, num]) => h('th', { class: num ? 'num' : null }, t)))), h('tbody', null, rows)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderDetail(id) {
|
|
132
|
+
const d = state.details.get(id);
|
|
133
|
+
if (d?.error) return errorState(d.error, () => loadDetail(id));
|
|
134
|
+
if (!d) return show(backLink(), h('div', { class: 'skeleton' }), h('div', { class: 'skeleton' }));
|
|
135
|
+
const { run, events, timeline: t, ci } = d;
|
|
136
|
+
const members = (state.runs ?? []).find((r) => r.id === run.id)?.children ?? [];
|
|
137
|
+
const reviews = reviewsByBead(events);
|
|
138
|
+
const findings = findingsOf(events);
|
|
139
|
+
const ciB = ciBadge(ci);
|
|
140
|
+
const ciRuns = (ci?.runs ?? []);
|
|
141
|
+
|
|
142
|
+
const children = t.children.map((c) => {
|
|
143
|
+
const rv = reviews.get(c.bead);
|
|
144
|
+
return h('tr', { 'data-testid': 'child-row' },
|
|
145
|
+
h('td', { class: 'mono' }, c.bead), h('td', null, c.model ?? dash), h('td', { class: 'num' }, c.wave ?? dash),
|
|
146
|
+
h('td', { class: 'num' }, formatDuration(c.durationMs)),
|
|
147
|
+
h('td', { class: 'num', 'data-col': 'tokens' }, formatTokens(tokensOf({ ...c.agents, agents: c.agents?.count }))),
|
|
148
|
+
h('td', { class: 'num', 'data-col': 'cost' }, formatCost(c.agents?.cost ?? null)),
|
|
149
|
+
h('td', null, c.result ? badge(resultBadge(c.result)) : (run.status === 'running' ? badge({ label: 'Working', tone: 'info' }, true) : dash)),
|
|
150
|
+
h('td', null, rv ? `${rv.verdict ?? dash}${rv.round ? ` · round ${rv.round}` : ''}` : dash),
|
|
151
|
+
h('td', { class: 'small' }, c.redBeforeGreen ?? dash));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const steps = t.steps.map((s) => h('li', { 'data-open': s.end ? null : true },
|
|
155
|
+
h('span', { class: 'dot' }),
|
|
156
|
+
h('span', null, stepLabel(s), s.sha ? h('span', { class: 'mono muted' }, ` ${s.sha.slice(0, 7)}`) : null),
|
|
157
|
+
h('span', { class: 'num muted' }, s.end ? formatDuration(s.durationMs) : 'running')));
|
|
158
|
+
|
|
159
|
+
const reviewTr = reviewRows(events).map((r) => h('tr', null, h('td', { class: 'mono' }, r.bead ?? dash), h('td', { class: 'num' }, r.round ?? dash), h('td', null, r.verdict ?? dash), h('td', { class: 'num' }, r.mustFix)));
|
|
160
|
+
const testRows = t.tests.map((x) => h('tr', null,
|
|
161
|
+
h('td', null, x.scope ?? dash), h('td', { class: 'num' }, x.passed),
|
|
162
|
+
h('td', { class: 'num' }, x.failed > 0 ? badge({ label: String(x.failed), tone: 'bad' }) : x.failed), h('td', { class: 'num' }, x.duration != null ? `${x.duration}s` : dash)));
|
|
163
|
+
|
|
164
|
+
const ciLinks = ciRuns.map((r) => {
|
|
165
|
+
const href = safeHref(r.url);
|
|
166
|
+
return h('li', null, href ? h('a', { class: 'link-btn', href, target: '_blank', rel: 'noopener noreferrer' }, r.name || 'Workflow run') : h('span', null, r.name || 'Workflow run'),
|
|
167
|
+
h('span', { class: 'small muted' }, r.conclusion ?? r.status));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
document.title = `${runTitle(run)} · ${appTitle()}`;
|
|
171
|
+
show(
|
|
172
|
+
backLink(),
|
|
173
|
+
h('div', { 'data-testid': 'run-detail', class: 'stack' },
|
|
174
|
+
h('div', { class: 'head' }, h('h1', null, runTitle(run)), badge(statusBadge(run.status), run.status === 'running')),
|
|
175
|
+
h('section', { class: 'card' },
|
|
176
|
+
h('div', { class: 'stats' },
|
|
177
|
+
stat('Kind', run.kind ?? dash), stat('Units', (run.units ?? []).join(', ') || dash),
|
|
178
|
+
stat('Started', formatStarted(run.started), run.started ? relativeTime(run.started) : null),
|
|
179
|
+
stat('Duration', formatDuration(run.status === 'running' && run.started ? Date.now() - Date.parse(run.started) : t.totals.durationMs), null, run.status === 'running' ? run.started : null),
|
|
180
|
+
stat('Tokens', formatTokens(tokensOf(t.totals))), stat('Est. cost (estimate)', formatCost(t.totals.cost)),
|
|
181
|
+
stat('Agents', String(t.totals.agents))),
|
|
182
|
+
t.totals.cost === null && t.totals.agents > 0 ? h('p', { class: 'small muted' }, 'Cost is unknown because at least one agent used a model without a price.') : null),
|
|
183
|
+
members.length ? section('Runs in this batch', table([['Status'], ['Run'], ['Started'], ['Duration', true], ['Est. cost', true], ['CI']], members.map((m) => runRow(m, false)))) : null,
|
|
184
|
+
section('Steps', t.steps.length ? h('ol', { class: 'steps' }, steps) : empty('No steps recorded yet.')),
|
|
185
|
+
section('Children', t.children.length
|
|
186
|
+
? table([['Bead'], ['Model'], ['Wave', true], ['Duration', true], ['Tokens', true], ['Est. cost', true], ['Result'], ['Review'], ['Red before green']], children)
|
|
187
|
+
: empty('No child tasks recorded yet.')),
|
|
188
|
+
h('div', { class: 'grid-2' },
|
|
189
|
+
section('Reviews', reviewTr.length ? table([['Bead'], ['Round', true], ['Verdict'], ['Must fix', true]], reviewTr) : empty('No reviews yet.')),
|
|
190
|
+
section('Tests', t.tests.length ? table([['Scope'], ['Passed', true], ['Failed', true], ['Time', true]], testRows) : empty('No test results yet.'))),
|
|
191
|
+
h('div', { class: 'grid-2' },
|
|
192
|
+
section('Findings filed', findings.length ? h('ul', { class: 'list' }, findings.map((f) => h('li', { class: 'mono' }, f))) : empty('No findings.')),
|
|
193
|
+
section('CI', ci ? [ciB ? badge(ciB) : null, h('p', { class: 'small muted mono' }, ci.sha.slice(0, 7)), ciRuns.length ? h('ul', { class: 'list' }, ciLinks) : empty('No workflow runs found for this commit.')] : empty('Nothing pushed yet.')))));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const backLink = () => h('a', { class: 'back', href: '#/' }, '← All runs');
|
|
197
|
+
|
|
198
|
+
async function loadDetail(id) {
|
|
199
|
+
try {
|
|
200
|
+
const d = await getJson(`/api/runs/${encodeURIComponent(id)}`);
|
|
201
|
+
state.details.set(id, d);
|
|
202
|
+
state.ci.set(id, d.ci ?? null);
|
|
203
|
+
} catch (e) { state.details.set(id, { error: e.message }); }
|
|
204
|
+
if (state.route.view === 'run' && state.route.id === id) renderDetail(id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function errorState(message, retry) {
|
|
208
|
+
show(backLink(), h('div', { class: 'state', 'data-tone': 'bad', 'data-testid': 'error-state', role: 'alert' },
|
|
209
|
+
h('h2', null, 'Could not load'), h('p', null, message), h('button', { class: 'btn', type: 'button', onclick: null }, 'Retry')));
|
|
210
|
+
app.querySelector('button').addEventListener('click', retry);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ---------- Routing and live updates ---------- */
|
|
214
|
+
function route() {
|
|
215
|
+
state.route = parseRoute(location.hash);
|
|
216
|
+
if (state.route.view === 'run') {
|
|
217
|
+
state.details.delete(state.route.id);
|
|
218
|
+
renderDetail(state.route.id);
|
|
219
|
+
loadDetail(state.route.id);
|
|
220
|
+
if (!state.runs) loadList();
|
|
221
|
+
} else {
|
|
222
|
+
renderList();
|
|
223
|
+
loadList();
|
|
224
|
+
}
|
|
225
|
+
window.scrollTo(0, 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let refreshTimer = null;
|
|
229
|
+
function onRunChanged(id) {
|
|
230
|
+
clearTimeout(refreshTimer);
|
|
231
|
+
refreshTimer = setTimeout(() => {
|
|
232
|
+
state.ci.delete(id);
|
|
233
|
+
loadList();
|
|
234
|
+
if (state.route.view === 'run') loadDetail(state.route.id);
|
|
235
|
+
}, 150);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function connect() {
|
|
239
|
+
const es = new EventSource('/api/events');
|
|
240
|
+
let dropped = false;
|
|
241
|
+
es.addEventListener('open', () => {
|
|
242
|
+
liveEl.dataset.state = 'live'; liveEl.textContent = 'Live';
|
|
243
|
+
if (dropped) { dropped = false; onRunChanged(null); } // events missed while disconnected
|
|
244
|
+
});
|
|
245
|
+
es.addEventListener('error', () => { dropped = true; liveEl.dataset.state = 'offline'; liveEl.textContent = 'Reconnecting'; });
|
|
246
|
+
es.addEventListener('run-changed', (e) => {
|
|
247
|
+
try { onRunChanged(JSON.parse(e.data).id); } catch { onRunChanged(null); }
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
window.addEventListener('hashchange', route);
|
|
252
|
+
// Keep relative times and running durations fresh.
|
|
253
|
+
const refreshMs = Number(new URLSearchParams(location.search).get('refresh')) || 10_000;
|
|
254
|
+
setInterval(() => {
|
|
255
|
+
if (state.route.view === 'list' && state.runs) renderList();
|
|
256
|
+
// Running detail: only the duration text ticks, the rest of the page (and focus) stays untouched.
|
|
257
|
+
for (const el of app.querySelectorAll('[data-live]')) el.textContent = formatDuration(Date.now() - Date.parse(el.dataset.live));
|
|
258
|
+
}, refreshMs);
|
|
259
|
+
route();
|
|
260
|
+
connect();
|