@phuthuycoding/kanban-flow 0.3.0
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/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/cli/args.js +219 -0
- package/dist/cli/commands/approve.js +44 -0
- package/dist/cli/commands/archive.js +245 -0
- package/dist/cli/commands/artifacts.js +100 -0
- package/dist/cli/commands/autoconfig.js +180 -0
- package/dist/cli/commands/cancel.js +129 -0
- package/dist/cli/commands/contexts.js +101 -0
- package/dist/cli/commands/doctor.js +35 -0
- package/dist/cli/commands/harness.js +60 -0
- package/dist/cli/commands/helpers.js +22 -0
- package/dist/cli/commands/init.js +119 -0
- package/dist/cli/commands/inspect.js +141 -0
- package/dist/cli/commands/new.js +80 -0
- package/dist/cli/commands/rules.js +69 -0
- package/dist/cli/commands/run.js +156 -0
- package/dist/cli/commands/stage.js +186 -0
- package/dist/cli/result.js +1 -0
- package/dist/dashboard/dashboard-view.js +238 -0
- package/dist/dashboard/dashboard.js +206 -0
- package/dist/harness/chain.js +41 -0
- package/dist/harness/config.js +168 -0
- package/dist/harness/prompt.js +105 -0
- package/dist/harness/run.js +245 -0
- package/dist/harness/session.js +78 -0
- package/dist/harness/supervise.js +65 -0
- package/dist/index.js +123 -0
- package/dist/integrations/agents.js +67 -0
- package/dist/integrations/hooks.js +59 -0
- package/dist/integrations/install.js +193 -0
- package/dist/project/bootstrap.js +358 -0
- package/dist/project/config.js +111 -0
- package/dist/project/contexts.js +98 -0
- package/dist/project/doctor.js +163 -0
- package/dist/shared/frontmatter.js +54 -0
- package/dist/shared/paths.js +78 -0
- package/dist/shared/time.js +5 -0
- package/dist/workflow/direction.js +56 -0
- package/dist/workflow/features.js +198 -0
- package/dist/workflow/findings.js +3 -0
- package/dist/workflow/schema.js +148 -0
- package/dist/workflow/secrets.js +52 -0
- package/dist/workflow/status.js +188 -0
- package/dist/workflow/validate-approval.js +25 -0
- package/dist/workflow/validate-artifacts.js +89 -0
- package/dist/workflow/validate-cancel.js +14 -0
- package/dist/workflow/validate-reports.js +121 -0
- package/dist/workflow/validate-traceability.js +91 -0
- package/dist/workflow/validate.js +73 -0
- package/docs/workflow/README.md +67 -0
- package/docs/workflow/artifacts.md +60 -0
- package/docs/workflow/cli-reference.md +78 -0
- package/docs/workflow/dashboard.md +35 -0
- package/docs/workflow/gates.md +103 -0
- package/docs/workflow/harness.md +144 -0
- package/docs/workflow/lifecycle.md +107 -0
- package/docs/workflow/skills.md +52 -0
- package/docs/workflow/source-layout.md +47 -0
- package/docs/workflow/state-machine.md +83 -0
- package/kanban-flow/review/rules/README.md +30 -0
- package/kanban-flow/review/rules/general.md +41 -0
- package/kanban-flow/review/rules/performance.md +29 -0
- package/kanban-flow/review/rules/security.md +32 -0
- package/kanban-flow/review/stacks/go.md +33 -0
- package/kanban-flow/review/stacks/java.md +38 -0
- package/kanban-flow/review/stacks/node.md +28 -0
- package/kanban-flow/review/stacks/php.md +30 -0
- package/kanban-flow/review/stacks/python.md +34 -0
- package/kanban-flow/review/stacks/ruby.md +32 -0
- package/kanban-flow/review/stacks/rust.md +33 -0
- package/kanban-flow/templates/phase-1-bug-report.md +76 -0
- package/kanban-flow/templates/phase-1-spec-requirement.md +67 -0
- package/kanban-flow/templates/phase-2-implementation-plan.md +85 -0
- package/kanban-flow/templates/phase-2-test-case.md +68 -0
- package/kanban-flow/templates/phase-2-use-case-diagram.md +18 -0
- package/kanban-flow/templates/phase-2-use-case-specification.md +33 -0
- package/kanban-flow/templates/phase-2-use-case.md +60 -0
- package/kanban-flow/templates/phase-4-testing-result.md +63 -0
- package/kanban-flow/templates/phase-5-review-report.md +68 -0
- package/kanban-flow/templates/phase-6-feature-report.md +78 -0
- package/package.json +63 -0
- package/skills/kanban-archive/SKILL.md +78 -0
- package/skills/kanban-brainstorm/SKILL.md +310 -0
- package/skills/kanban-bug/SKILL.md +55 -0
- package/skills/kanban-flow/SKILL.md +136 -0
- package/skills/kanban-implement/SKILL.md +72 -0
- package/skills/kanban-plan/SKILL.md +102 -0
- package/skills/kanban-review/SKILL.md +90 -0
- package/skills/kanban-test/SKILL.md +76 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { relative, join } from "node:path";
|
|
2
|
+
import { findFeature, listFeatures } from "../../workflow/features.js";
|
|
3
|
+
import { STAGES } from "../../workflow/schema.js";
|
|
4
|
+
import { readProjectConfig } from "../../project/config.js";
|
|
5
|
+
import { resolveChain } from "../../harness/prompt.js";
|
|
6
|
+
import { provisionSession } from "../../harness/session.js";
|
|
7
|
+
import { executeChain, planFor } from "../../harness/chain.js";
|
|
8
|
+
import { isPidAlive, runningRun, DEFAULT_TIMEOUT_MS } from "../../harness/run.js";
|
|
9
|
+
import { startDetached, superviseRun } from "../../harness/supervise.js";
|
|
10
|
+
import { findRoot } from "./helpers.js";
|
|
11
|
+
function parseTimeout(raw) {
|
|
12
|
+
if (raw === undefined)
|
|
13
|
+
return DEFAULT_TIMEOUT_MS;
|
|
14
|
+
const minutes = Number(raw);
|
|
15
|
+
if (!Number.isFinite(minutes) || minutes < 0)
|
|
16
|
+
throw new Error(`Invalid --timeout '${String(raw)}': expected minutes (0 = no limit).`);
|
|
17
|
+
return Math.round(minutes * 60_000);
|
|
18
|
+
}
|
|
19
|
+
function renderOutcome(root, feature, outcome) {
|
|
20
|
+
const r = outcome.record;
|
|
21
|
+
const lines = [
|
|
22
|
+
`${outcome.ok ? "✓" : "✗"} ${r.chain ? `[${r.chain.index}/${r.chain.total}] ` : ""}${r.role} (${r.runner}) run ${r.id} @ ${r.stage} (${r.mode}) — ${r.status}${r.exitCode === undefined ? "" : `, exit ${r.exitCode}`}`,
|
|
23
|
+
` Log: ${relative(root, join(feature.dir, r.log))}`,
|
|
24
|
+
` STATUS: ${r.statusLine ?? "(missing)"}`,
|
|
25
|
+
];
|
|
26
|
+
if (r.summary)
|
|
27
|
+
lines.push(` Summary: ${r.summary}`);
|
|
28
|
+
if (r.usage)
|
|
29
|
+
lines.push(` Usage: ${r.usage.input} in / ${r.usage.output} out${r.usage.costUsd === undefined ? "" : ` / $${r.usage.costUsd.toFixed(4)}`}`);
|
|
30
|
+
if (r.warning)
|
|
31
|
+
lines.push(` ⚠ ${r.warning}`);
|
|
32
|
+
if (r.error)
|
|
33
|
+
lines.push(` ✗ ${r.error}`);
|
|
34
|
+
if (outcome.note)
|
|
35
|
+
lines.push(` ${outcome.note}`);
|
|
36
|
+
if (!outcome.ok && r.statusLine === null && r.exitCode === 0)
|
|
37
|
+
lines.push(" Worker exited 0 without a STATUS line — not treated as done.");
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
function renderChain(root, feature, summary) {
|
|
41
|
+
const lines = summary.outcomes.map((o) => renderOutcome(root, feature, o));
|
|
42
|
+
if (summary.stoppedAt) {
|
|
43
|
+
lines.push(`✗ Chain stopped at role "${summary.stoppedAt}"${summary.skipped.length > 0 ? `; not run: ${summary.skipped.join(", ")}` : ""}`);
|
|
44
|
+
}
|
|
45
|
+
return lines.join("\n\n");
|
|
46
|
+
}
|
|
47
|
+
export async function cmdRun(args, cwd) {
|
|
48
|
+
const name = args.positionals[0];
|
|
49
|
+
if (!name)
|
|
50
|
+
return { code: 1, stdout: "Usage: kf run <feature> [--stage <s>] [--role <r>] [--fresh] [--detach] [--timeout <min>] [--dry-run]", stderr: "missing feature" };
|
|
51
|
+
if (args.options.agent !== undefined) {
|
|
52
|
+
return { code: 1, stdout: "--agent was replaced by --role: stages are assigned to roles (harness.roles), and a role points at a runner. See: kf harness", stderr: "agent flag removed" };
|
|
53
|
+
}
|
|
54
|
+
const root = await findRoot(cwd);
|
|
55
|
+
if (!root.ok)
|
|
56
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
57
|
+
const feature = findFeature(root.root, name);
|
|
58
|
+
if (!feature)
|
|
59
|
+
return { code: 1, stdout: `Unknown feature '${name}'. Run: kf list`, stderr: "unknown feature" };
|
|
60
|
+
if (typeof args.options.supervise === "string") {
|
|
61
|
+
const summary = await superviseRun(root.root, name, args.options.supervise);
|
|
62
|
+
return { code: summary.ok ? 0 : 1, stdout: renderChain(root.root, findFeature(root.root, name) ?? feature, summary) };
|
|
63
|
+
}
|
|
64
|
+
const stageRaw = args.options.stage;
|
|
65
|
+
if (stageRaw !== undefined && !STAGES.includes(String(stageRaw))) {
|
|
66
|
+
return { code: 1, stdout: `Unknown stage '${String(stageRaw)}'. Stages: ${STAGES.join(", ")}`, stderr: "unknown stage" };
|
|
67
|
+
}
|
|
68
|
+
const cfg = readProjectConfig(root.root);
|
|
69
|
+
const resolved = resolveChain(cfg.harness, feature, root.root, {
|
|
70
|
+
stage: stageRaw === undefined ? undefined : String(stageRaw),
|
|
71
|
+
role: typeof args.options.role === "string" ? args.options.role : undefined,
|
|
72
|
+
});
|
|
73
|
+
if (!resolved.ok)
|
|
74
|
+
return { code: 1, stdout: resolved.reason, stderr: "not assigned" };
|
|
75
|
+
const chain = resolved.chain;
|
|
76
|
+
const timeoutMs = parseTimeout(args.options.timeout);
|
|
77
|
+
const fresh = Boolean(args.options.fresh);
|
|
78
|
+
if (args.options["dry-run"]) {
|
|
79
|
+
const blocks = chain.map((assignment, index) => {
|
|
80
|
+
const plan = planFor(root.root, feature, assignment, { fresh, timeoutMs }, index === 0 ? null : { role: chain[index - 1].role, output: chain[index - 1].output, log: "<log of the previous run, known at run time>" }, { id: "<chain>", index: index + 1, total: chain.length });
|
|
81
|
+
const provisioned = provisionSession(plan.runner, feature.meta?.sessions?.[plan.role], plan.prompt, fresh);
|
|
82
|
+
return `[${index + 1}/${chain.length}] ${assignment.role} (${assignment.runnerName}) — ${provisioned.mode}\n\nargv:\n${provisioned.argv.map((a) => ` ${JSON.stringify(a)}`).join("\n")}\n\nprompt:\n${plan.prompt}`;
|
|
83
|
+
});
|
|
84
|
+
return { code: 0, stdout: `Dry run — stage ${chain[0].stage}, chain: ${chain.map((a) => a.role).join(" → ")}\n\n${blocks.join("\n\n---\n\n")}` };
|
|
85
|
+
}
|
|
86
|
+
const active = runningRun(feature);
|
|
87
|
+
if (active)
|
|
88
|
+
return { code: 1, stdout: `Run ${active.id} (${active.role} @ ${active.stage}) is still running for '${name}'. Wait for it or check: kf runs ${name}`, stderr: "run in progress" };
|
|
89
|
+
if (args.options.detach) {
|
|
90
|
+
const record = await startDetached(root.root, name, chain, { fresh, timeoutMs });
|
|
91
|
+
const rest = chain.slice(1).map((a) => a.role);
|
|
92
|
+
return {
|
|
93
|
+
code: 0,
|
|
94
|
+
stdout: `▶ Chain ${record.id} started detached: ${chain.map((a) => `${a.role} (${a.runnerName})`).join(" → ")}\n Log: ${relative(root.root, join(feature.dir, record.log))}${rest.length > 0 ? `\n Remaining roles run after the first one finishes: ${rest.join(", ")}` : ""}\n Poll with: kf runs ${name}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const summary = await executeChain(root.root, name, chain, { fresh, timeoutMs });
|
|
98
|
+
return {
|
|
99
|
+
code: summary.ok ? 0 : 1,
|
|
100
|
+
stdout: renderChain(root.root, findFeature(root.root, name) ?? feature, summary),
|
|
101
|
+
stderr: summary.ok ? undefined : "chain not done",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A chain that stopped early looks exactly like a finished one unless we say so:
|
|
106
|
+
* flag the last run of a chain whose position is short of `total` and that has
|
|
107
|
+
* no live run after it, so a detached supervisor dying mid-chain is visible.
|
|
108
|
+
*/
|
|
109
|
+
export function runViews(features) {
|
|
110
|
+
const views = [];
|
|
111
|
+
for (const f of features) {
|
|
112
|
+
const runs = f.meta?.runs ?? [];
|
|
113
|
+
const lastOfChain = new Map();
|
|
114
|
+
for (const r of runs)
|
|
115
|
+
if (r.chain)
|
|
116
|
+
lastOfChain.set(r.chain.id, r);
|
|
117
|
+
const anyLive = runs.some((r) => r.status === "running" && (isPidAlive(r.pid) || isPidAlive(r.supervisorPid)));
|
|
118
|
+
for (const r of runs) {
|
|
119
|
+
const lost = r.status === "running" && !isPidAlive(r.pid) && !isPidAlive(r.supervisorPid);
|
|
120
|
+
const broken = Boolean(r.chain && r.chain.index < r.chain.total && lastOfChain.get(r.chain.id)?.id === r.id && !anyLive);
|
|
121
|
+
views.push({
|
|
122
|
+
...r, feature: f.name, chainBroken: broken,
|
|
123
|
+
displayStatus: lost ? "failed (supervisor lost)" : broken ? `${r.status} (chain stopped ${r.chain.index}/${r.chain.total})` : r.status,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return views.sort((a, b) => b.at.localeCompare(a.at));
|
|
128
|
+
}
|
|
129
|
+
export async function cmdRuns(args, cwd) {
|
|
130
|
+
const root = await findRoot(cwd);
|
|
131
|
+
if (!root.ok)
|
|
132
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
133
|
+
const name = args.positionals[0];
|
|
134
|
+
let features;
|
|
135
|
+
if (name) {
|
|
136
|
+
const f = findFeature(root.root, name);
|
|
137
|
+
if (!f)
|
|
138
|
+
return { code: 1, stdout: `Unknown feature '${name}'. Run: kf list`, stderr: "unknown feature" };
|
|
139
|
+
features = [f];
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
// Runs of finished or dropped work are history: only show them when asked by name.
|
|
143
|
+
features = listFeatures(root.root).filter((f) => f.stage !== "dones" && f.stage !== "cancelled");
|
|
144
|
+
}
|
|
145
|
+
const views = runViews(features);
|
|
146
|
+
if (args.options.json)
|
|
147
|
+
return { code: 0, stdout: JSON.stringify(views, null, 2) };
|
|
148
|
+
if (views.length === 0)
|
|
149
|
+
return { code: 0, stdout: name ? `No runs for '${name}'.` : "No runs." };
|
|
150
|
+
const lines = views.map((v) => `${v.id} ${v.feature.padEnd(20)} ${(v.chain ? `${v.chain.index}/${v.chain.total} ` : "").padEnd(4)}${v.role.padEnd(12)} ${v.runner.padEnd(10)} ${v.stage.padEnd(14)} ${v.mode.padEnd(6)} ${v.displayStatus.padEnd(30)} ${v.at}${v.statusLine ? ` STATUS: ${v.statusLine}` : ""}`);
|
|
151
|
+
const broken = views.filter((v) => v.chainBroken);
|
|
152
|
+
if (broken.length > 0) {
|
|
153
|
+
lines.push("", ...broken.map((v) => `⚠ ${v.feature}: role chain stopped at ${v.role} (${v.chain.index}/${v.chain.total}); the remaining roles never ran — re-run the stage with kf run.`));
|
|
154
|
+
}
|
|
155
|
+
return { code: 0, stdout: lines.join("\n") };
|
|
156
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { rename } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { STAGES, TRANSITIONS } from "../../workflow/schema.js";
|
|
6
|
+
import { findFeature, stageDir, writeFeatureMeta } from "../../workflow/features.js";
|
|
7
|
+
import { cmdArchive } from "./archive.js";
|
|
8
|
+
import { validateFeature, checkDirectionGate, renderValidateText } from "../../workflow/validate.js";
|
|
9
|
+
import { runHook, resolveHook } from "../../integrations/hooks.js";
|
|
10
|
+
import { findRoot, recordBypasses, bypassNote } from "./helpers.js";
|
|
11
|
+
export async function cmdStage(args, cwd) {
|
|
12
|
+
const [name, toRaw] = args.positionals;
|
|
13
|
+
if (!name || !toRaw) {
|
|
14
|
+
return {
|
|
15
|
+
code: 1,
|
|
16
|
+
stdout: "Usage: kf stage <feature> <next-stage>\nStages: brainstorm → planning → backlog → implementation → testing → review → dones",
|
|
17
|
+
stderr: "missing args",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const to = toRaw;
|
|
21
|
+
if (!STAGES.includes(to)) {
|
|
22
|
+
return { code: 1, stdout: `Unknown stage '${to}'. Stages: ${STAGES.join(" → ")}`, stderr: "unknown stage" };
|
|
23
|
+
}
|
|
24
|
+
const root = await findRoot(cwd);
|
|
25
|
+
if (!root.ok)
|
|
26
|
+
return { code: 1, stdout: root.err, stderr: "no works" };
|
|
27
|
+
const f = findFeature(root.root, name);
|
|
28
|
+
if (!f)
|
|
29
|
+
return { code: 1, stdout: `Unknown feature '${name}'. Run: kf list`, stderr: "unknown feature" };
|
|
30
|
+
if (f.stage === "dones") {
|
|
31
|
+
return { code: 1, stdout: `Feature '${name}' is already archived (dones).`, stderr: "already dones" };
|
|
32
|
+
}
|
|
33
|
+
// A cancelled item has exactly one way back: the stage it was cancelled from.
|
|
34
|
+
let isReopen = false;
|
|
35
|
+
if (f.stage === "cancelled") {
|
|
36
|
+
const from = f.meta?.cancellation?.fromStage;
|
|
37
|
+
if (!from) {
|
|
38
|
+
return { code: 1, stdout: `'${name}' is cancelled but its metadata has no fromStage, so kf cannot tell where it belongs. Re-run with --force to place it anywhere.`, stderr: "cancellation missing" };
|
|
39
|
+
}
|
|
40
|
+
if (to !== from && !args.options.force) {
|
|
41
|
+
return { code: 1, stdout: `Cancelled '${name}' can only be reopened at ${from} (where it was cancelled). Run: kf stage ${name} ${from}`, stderr: "wrong reopen stage" };
|
|
42
|
+
}
|
|
43
|
+
isReopen = to === from;
|
|
44
|
+
}
|
|
45
|
+
const allowed = TRANSITIONS[f.stage];
|
|
46
|
+
if (f.stage !== "cancelled" && !allowed.includes(to)) {
|
|
47
|
+
const desc = allowed.join(", ");
|
|
48
|
+
return {
|
|
49
|
+
code: 1,
|
|
50
|
+
stdout: `Cannot move '${name}' ${f.stage} → ${to}. Allowed: ${desc}.`,
|
|
51
|
+
stderr: "not allowed transition",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Reopening into `dones` is putting an item back where it was, not archiving it again. Routing
|
|
55
|
+
// it through archive made `kf cancel`'s own advertised reopen command refuse itself, with no
|
|
56
|
+
// escape: archive rejects a cancelled item before it ever looks at --force. The move happens
|
|
57
|
+
// below, and the archive state it had is restored right after — see the isReopen tail.
|
|
58
|
+
if (to === "dones" && !isReopen)
|
|
59
|
+
return cmdArchive({ ...args, command: "archive", positionals: [name] }, cwd);
|
|
60
|
+
// Gate: feature must be valid for its CURRENT stage before leaving it.
|
|
61
|
+
const force = Boolean(args.options.force);
|
|
62
|
+
const forcedCodes = [];
|
|
63
|
+
// Returning to planning is checked against the brainstorm gate: the requirement must still be
|
|
64
|
+
// confirmed. A cancelled item is exempt — pretending it sits in brainstorm walked straight past
|
|
65
|
+
// the `cancelled` shortcut inside validateFeature, so `kf validate` called the item valid while
|
|
66
|
+
// `kf stage` refused the very reopen `kf cancel` prints, and blamed a stage it was not in. The
|
|
67
|
+
// only way through was --force, which stamps a permanent bypass for a gate that should not hold.
|
|
68
|
+
const check = to === "planning" && f.stage !== "brainstorm" && f.stage !== "cancelled"
|
|
69
|
+
? validateFeature({ ...f, stage: "brainstorm" }, false, false)
|
|
70
|
+
: validateFeature(f);
|
|
71
|
+
if (!check.valid) {
|
|
72
|
+
if (!force) {
|
|
73
|
+
return {
|
|
74
|
+
code: 1,
|
|
75
|
+
stdout: `Gate failed for '${name}' (${f.stage}). Fix validation before moving:\n\n${renderValidateText(check)}\n\nOr re-run with --force.`,
|
|
76
|
+
stderr: "gate failed",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
forcedCodes.push(...check.issues.filter((i) => i.severity === "ERROR").map((i) => i.code));
|
|
80
|
+
}
|
|
81
|
+
// Directional gate: PASS/FAIL/REQUIREMENT_BUG semantics of the reports.
|
|
82
|
+
const direction = checkDirectionGate(f, to);
|
|
83
|
+
if (direction.length > 0) {
|
|
84
|
+
if (!force) {
|
|
85
|
+
const lines = direction.map((i) => ` [${i.severity}] ${i.file}: ${i.message} (${i.code})`).join("\n");
|
|
86
|
+
return {
|
|
87
|
+
code: 1,
|
|
88
|
+
stdout: `Cannot move '${name}' ${f.stage} → ${to} — report status blocks this direction:\n\n${lines}\n\nOr re-run with --force.`,
|
|
89
|
+
stderr: "direction gate failed",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
forcedCodes.push(...direction.map((i) => i.code));
|
|
93
|
+
}
|
|
94
|
+
// Phase hook: run the hook of the stage we are entering (before the move).
|
|
95
|
+
// If it exits non-zero the transition is refused, unless --skip-hooks.
|
|
96
|
+
const skipHooks = Boolean(args.options["skip-hooks"]);
|
|
97
|
+
let hookResult = null;
|
|
98
|
+
const skippedHook = skipHooks ? resolveHook(root.root, to) : null;
|
|
99
|
+
if (!skipHooks) {
|
|
100
|
+
hookResult = runHook(root.root, {
|
|
101
|
+
feature: f.name,
|
|
102
|
+
context: f.context,
|
|
103
|
+
dir: f.dir,
|
|
104
|
+
root: root.root,
|
|
105
|
+
from: f.stage,
|
|
106
|
+
to,
|
|
107
|
+
approval: to === "planning" ? "pending" : f.meta?.approval?.status ?? "pending",
|
|
108
|
+
});
|
|
109
|
+
if (hookResult.ran && !hookResult.ok) {
|
|
110
|
+
return {
|
|
111
|
+
code: 1,
|
|
112
|
+
stdout: `Hook '${to}' failed (exit ${hookResult.code})${hookResult.hook ? ` [${hookResult.hook.path}]` : ""}.\nTransition refused.\n\n${hookResult.output}\n\nRe-run with --skip-hooks to bypass.`,
|
|
113
|
+
stderr: "hook failed",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const dest = stageDir(root.root, to);
|
|
118
|
+
const target = join(dest, f.folder);
|
|
119
|
+
if (existsSync(target)) {
|
|
120
|
+
return { code: 1, stdout: `Target already exists: ${target}`, stderr: "target exists" };
|
|
121
|
+
}
|
|
122
|
+
if (!f.meta)
|
|
123
|
+
return { code: 1, stdout: "Feature metadata is missing.", stderr: "metadata missing" };
|
|
124
|
+
const recorded = recordBypasses(f.stage, to, forcedCodes, skippedHook);
|
|
125
|
+
let metadataUpdated = false;
|
|
126
|
+
try {
|
|
127
|
+
let meta = recorded.length > 0 ? { ...f.meta, bypasses: [...(f.meta.bypasses ?? []), ...recorded] } : f.meta;
|
|
128
|
+
// Putting an item back in `dones` means putting back the state it had there. `status` does
|
|
129
|
+
// not depend on validation, so restore it here rather than leaving it to the doc re-sync,
|
|
130
|
+
// which can legitimately refuse on an item that was never fully valid.
|
|
131
|
+
if (f.stage === "cancelled")
|
|
132
|
+
meta = { ...meta, cancellation: undefined, status: isReopen && to === "dones" ? "archived" : undefined };
|
|
133
|
+
if (to === "planning") {
|
|
134
|
+
await writeFeatureMeta(f.dir, { ...meta, approval: { status: "pending" }, executionId: undefined });
|
|
135
|
+
metadataUpdated = true;
|
|
136
|
+
}
|
|
137
|
+
else if (to === "testing" || to === "implementation") {
|
|
138
|
+
await writeFeatureMeta(f.dir, { ...meta, executionId: to === "testing" ? randomUUID() : undefined });
|
|
139
|
+
metadataUpdated = true;
|
|
140
|
+
}
|
|
141
|
+
else if (recorded.length > 0 || f.stage === "cancelled") {
|
|
142
|
+
await writeFeatureMeta(f.dir, meta);
|
|
143
|
+
metadataUpdated = true;
|
|
144
|
+
}
|
|
145
|
+
await rename(f.dir, target);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (metadataUpdated) {
|
|
149
|
+
try {
|
|
150
|
+
await writeFeatureMeta(f.dir, f.meta);
|
|
151
|
+
}
|
|
152
|
+
catch (rollbackError) {
|
|
153
|
+
throw new AggregateError([err, rollbackError], `Transition failed and metadata rollback was incomplete for '${name}'.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
const hookNote = hookResult?.ran ? `\n Hook '${to}' ran [${hookResult.hook?.source ?? ""}]` : "";
|
|
159
|
+
const moved = `✓ Moved '${name}' ${f.stage} → ${to}\n ${target}${hookNote}${bypassNote(recorded)}`;
|
|
160
|
+
// An item reopened into `dones` must end up as archived as it was: `status: "archived"` back,
|
|
161
|
+
// and canonical docs re-synced, since `kf cancel --purge-docs` may have deleted them. Archive
|
|
162
|
+
// is idempotent once the item is in `dones`, so hand off rather than duplicate its logic.
|
|
163
|
+
if (isReopen && to === "dones") {
|
|
164
|
+
// The caller's flags are deliberately NOT forwarded. `--force` on `kf stage` means "skip a
|
|
165
|
+
// gate"; inside archive it also means "overwrite canonical docs that changed since the
|
|
166
|
+
// snapshot", which would destroy hand edits on a command that never offered to. A re-sync
|
|
167
|
+
// that refuses is the correct outcome here, and archive says why.
|
|
168
|
+
//
|
|
169
|
+
// Archive can throw as well as return non-zero — a missing source artifact, an unwritable
|
|
170
|
+
// docs tree. The move and the metadata write have already committed by now, so a throw must
|
|
171
|
+
// not turn a successful reopen into exit 1.
|
|
172
|
+
let resync;
|
|
173
|
+
try {
|
|
174
|
+
resync = await cmdArchive({ command: "archive", positionals: [name], options: {} }, cwd);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
178
|
+
return { code: 0, stdout: `${moved}\n Canonical docs were not re-synced: ${why}\n Fix that, then run: kf archive ${name}` };
|
|
179
|
+
}
|
|
180
|
+
if (resync.code !== 0) {
|
|
181
|
+
return { code: 0, stdout: `${moved}\n Canonical docs were not re-synced. Run: kf archive ${name}\n${resync.stdout}` };
|
|
182
|
+
}
|
|
183
|
+
return { code: 0, stdout: `${moved}\n${resync.stdout}` };
|
|
184
|
+
}
|
|
185
|
+
return { code: 0, stdout: moved };
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
export function renderDashboardHtml() {
|
|
2
|
+
return `<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>kanban-flow dashboard</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: dark; --bg: #0b1120; --panel: #111c30; --border: #2b3a52;
|
|
10
|
+
--text: #e8eef8; --muted: #a9b8ce; --feature: #22d3ee; --bug: #fbbf24; }
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
body { margin: 0; background: var(--bg); color: var(--text);
|
|
13
|
+
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
|
14
|
+
main { max-width: 1280px; margin: auto; padding: 32px 24px; }
|
|
15
|
+
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
|
16
|
+
h1 { font-size: 26px; letter-spacing: -.04em; margin: 4px 0 6px; }
|
|
17
|
+
h2 { font-size: 16px; margin: 0; }
|
|
18
|
+
p { margin: 0; }
|
|
19
|
+
.eyebrow { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--feature); }
|
|
20
|
+
.muted, .caption { color: var(--muted); }
|
|
21
|
+
.caption { font-size: 12px; margin-top: 6px; }
|
|
22
|
+
.toolbar { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin: 24px 0; }
|
|
23
|
+
label { display: grid; gap: 6px; font-size: 12px; color: var(--muted); }
|
|
24
|
+
select, button { font: inherit; min-height: 44px; border: 1px solid var(--border); border-radius: 8px;
|
|
25
|
+
background: var(--panel); color: var(--text); padding: 8px 12px; }
|
|
26
|
+
select { min-width: 180px; max-width: 100%; cursor: pointer; }
|
|
27
|
+
button { cursor: pointer; }
|
|
28
|
+
button:hover { border-color: var(--feature); }
|
|
29
|
+
button:disabled { cursor: wait; opacity: .65; }
|
|
30
|
+
select:focus-visible, button:focus-visible { outline: 2px solid var(--feature); outline-offset: 3px; }
|
|
31
|
+
.updated { margin-left: auto; color: var(--muted); font-size: 12px; align-self: center; }
|
|
32
|
+
#root { font-size: 12px; overflow-wrap: anywhere; margin-top: 12px; }
|
|
33
|
+
#error { background: #452028; color: #fecdd3; border: 1px solid #a94a5a; border-radius: 8px; padding: 12px; margin-bottom: 20px; }
|
|
34
|
+
[hidden] { display: none !important; }
|
|
35
|
+
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
|
36
|
+
.kpi, .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; }
|
|
37
|
+
.kpi { padding: 18px; }
|
|
38
|
+
.kpi-label { color: var(--muted); font-size: 12px; }
|
|
39
|
+
.kpi-value { font-size: 32px; font-weight: 650; letter-spacing: -.04em; margin: 6px 0; font-variant-numeric: tabular-nums; }
|
|
40
|
+
.kpi-note { font-size: 11px; color: var(--muted); }
|
|
41
|
+
.charts { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; }
|
|
42
|
+
.panel { padding: 22px; min-width: 0; }
|
|
43
|
+
.panel-heading { margin-bottom: 20px; }
|
|
44
|
+
.legend { display: flex; flex-wrap: wrap; gap: 16px; color: var(--muted); font-size: 12px; margin-bottom: 18px; }
|
|
45
|
+
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
46
|
+
.swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
|
|
47
|
+
.feature { background: var(--feature); }
|
|
48
|
+
.bug { background: var(--bug); }
|
|
49
|
+
.bars { display: grid; gap: 15px; }
|
|
50
|
+
.chart-row { display: grid; grid-template-columns: minmax(90px, 120px) minmax(0, 1fr) 36px; align-items: center; gap: 12px; }
|
|
51
|
+
.chart-label { overflow-wrap: anywhere; font-size: 12px; }
|
|
52
|
+
.track { display: flex; height: 12px; background: #26354c; border-radius: 4px; overflow: hidden; }
|
|
53
|
+
.segment { display: block; height: 100%; }
|
|
54
|
+
.chart-value { font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; }
|
|
55
|
+
.scroll-chart { max-height: 340px; overflow: auto; padding-right: 4px; }
|
|
56
|
+
.donut-layout { display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
|
|
57
|
+
.donut { width: 180px; height: 180px; flex-shrink: 0; }
|
|
58
|
+
.donut text { fill: var(--text); text-anchor: middle; }
|
|
59
|
+
.donut-total { font-size: 28px; font-weight: 650; }
|
|
60
|
+
.donut-label { font-size: 10px; fill: var(--muted) !important; }
|
|
61
|
+
.kind-legend { display: grid; gap: 12px; flex: 1; min-width: 120px; }
|
|
62
|
+
.kind-row { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 8px; }
|
|
63
|
+
.kind-row small { color: var(--muted); }
|
|
64
|
+
.task-summary { margin-top: 28px; padding-top: 22px; border-top: 1px solid var(--border); }
|
|
65
|
+
.task-line { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
|
66
|
+
.empty { padding: 24px 0; color: var(--muted); text-align: center; }
|
|
67
|
+
footer { margin-top: 24px; font-size: 12px; color: var(--muted); }
|
|
68
|
+
@media (max-width: 1000px) { .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
|
69
|
+
@media (max-width: 720px) {
|
|
70
|
+
main { padding: 24px 16px; }
|
|
71
|
+
header { display: block; }
|
|
72
|
+
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
73
|
+
.charts { grid-template-columns: minmax(0, 1fr); }
|
|
74
|
+
.toolbar label { flex: 1; min-width: 140px; }
|
|
75
|
+
select { min-width: 0; width: 100%; }
|
|
76
|
+
.updated { width: 100%; margin-left: 0; }
|
|
77
|
+
.panel { padding: 18px; }
|
|
78
|
+
.chart-row { grid-template-columns: 90px minmax(0, 1fr) 30px; gap: 8px; }
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
<main>
|
|
84
|
+
<header>
|
|
85
|
+
<div><div class="eyebrow">kanban-flow / Analytics</div><h1>Workflow overview</h1>
|
|
86
|
+
<p class="muted">Feature and bug counts, and how far execution has got.</p><p class="muted" id="root"></p></div>
|
|
87
|
+
<button id="refresh" type="button">Refresh</button>
|
|
88
|
+
</header>
|
|
89
|
+
<div class="toolbar">
|
|
90
|
+
<label>Context<select id="context"><option value="">All contexts</option></select></label>
|
|
91
|
+
<label>Work item kind<select id="kind"><option value="">Features and bugs</option><option value="feature">Feature</option><option value="bug">Bug</option></select></label>
|
|
92
|
+
<p class="updated">Updated <span id="ts">—</span> · refreshes every 5 seconds</p>
|
|
93
|
+
</div>
|
|
94
|
+
<div id="error" role="alert" hidden></div>
|
|
95
|
+
<section class="kpis" id="kpis" aria-label="Summary metrics"><p class="muted">Loading metrics…</p></section>
|
|
96
|
+
<div class="charts">
|
|
97
|
+
<section class="panel" aria-labelledby="stages-title">
|
|
98
|
+
<div class="panel-heading"><h2 id="stages-title">By stage</h2><p class="caption">How many work items sit in each stage right now.</p></div>
|
|
99
|
+
<div class="legend"><span><i class="swatch feature"></i>Feature</span><span><i class="swatch bug"></i>Bug</span></div>
|
|
100
|
+
<div class="bars" id="stage-chart"></div>
|
|
101
|
+
</section>
|
|
102
|
+
<section class="panel" aria-labelledby="kind-title">
|
|
103
|
+
<div class="panel-heading"><h2 id="kind-title">Features and bugs</h2><p class="caption">The split under the current filter.</p></div>
|
|
104
|
+
<div class="donut-layout" id="kind-chart"></div>
|
|
105
|
+
<div class="task-summary" id="task-chart"></div>
|
|
106
|
+
</section>
|
|
107
|
+
<section class="panel" aria-labelledby="context-title">
|
|
108
|
+
<div class="panel-heading"><h2 id="context-title">By context</h2><p class="caption">Feature and bug volume compared across contexts.</p></div>
|
|
109
|
+
<div class="bars scroll-chart" id="context-chart"></div>
|
|
110
|
+
</section>
|
|
111
|
+
<section class="panel" aria-labelledby="approval-title">
|
|
112
|
+
<div class="panel-heading"><h2 id="approval-title">Approval state</h2><p class="caption">Work items from planning through review, backlog included.</p></div>
|
|
113
|
+
<div class="bars" id="approval-chart"></div>
|
|
114
|
+
</section>
|
|
115
|
+
</div>
|
|
116
|
+
<footer>These numbers are a snapshot of .works/. Task progress counts only items in implementation, testing and review that have a tasks.md. It is not a test pass rate or coverage.</footer>
|
|
117
|
+
</main>
|
|
118
|
+
<script>
|
|
119
|
+
const $ = (selector) => document.querySelector(selector);
|
|
120
|
+
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) =>
|
|
121
|
+
({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[char]));
|
|
122
|
+
const percent = (value) => value === null ? '—' : value + '%';
|
|
123
|
+
const colors = { feature: '#22d3ee', bug: '#fbbf24', pending: '#a9b8ce', approved: '#4ade80', changed: '#fb7185' };
|
|
124
|
+
function bars(rows, stacked) {
|
|
125
|
+
const maximum = Math.max(1, ...rows.map((row) => row.count));
|
|
126
|
+
return rows.map((row) => {
|
|
127
|
+
const label = stacked
|
|
128
|
+
? row.label + ': ' + row.features + ' feature, ' + row.bugs + ' bug'
|
|
129
|
+
: row.label + ': ' + row.count;
|
|
130
|
+
const segments = stacked
|
|
131
|
+
? '<i class="segment feature" style="width:' + (100 * row.features / maximum) + '%"></i>'
|
|
132
|
+
+ '<i class="segment bug" style="width:' + (100 * row.bugs / maximum) + '%"></i>'
|
|
133
|
+
: '<i class="segment" style="background:' + colors[row.id] + ';width:' + (100 * row.count / maximum) + '%"></i>';
|
|
134
|
+
return '<div class="chart-row"><span class="chart-label">' + escapeHtml(row.label) + '</span>'
|
|
135
|
+
+ '<div class="track" role="img" aria-label="' + escapeHtml(label) + '" title="' + escapeHtml(label) + '">' + segments + '</div>'
|
|
136
|
+
+ '<span class="chart-value">' + row.count + '</span></div>';
|
|
137
|
+
}).join('');
|
|
138
|
+
}
|
|
139
|
+
function render(data) {
|
|
140
|
+
const m = data.metrics;
|
|
141
|
+
const kpis = [
|
|
142
|
+
['Work items', m.total, m.features + ' feature · ' + m.bugs + ' bug'],
|
|
143
|
+
['In execution', m.executing, 'Implementation · testing · review'],
|
|
144
|
+
['Backlog', m.backlog, 'Awaiting a decision to start'],
|
|
145
|
+
['Completed', m.completed, percent(m.completionRate) + ' of work items'],
|
|
146
|
+
['Cancelled', m.cancelled, 'Left out of the completion rate'],
|
|
147
|
+
['Gate bypasses', m.bypassed, 'Work items using --force / --skip-hooks'],
|
|
148
|
+
['Task progress', percent(m.tasks.completionRate), m.tasks.done + '/' + m.tasks.total + ' tasks done'],
|
|
149
|
+
];
|
|
150
|
+
$('#kpis').innerHTML = kpis.map(([label, value, note]) => '<div class="kpi"><p class="kpi-label">'
|
|
151
|
+
+ escapeHtml(label) + '</p><p class="kpi-value">' + escapeHtml(value)
|
|
152
|
+
+ '</p><p class="kpi-note">' + escapeHtml(note) + '</p></div>').join('');
|
|
153
|
+
$('#stage-chart').innerHTML = bars(data.charts.byStage, true);
|
|
154
|
+
$('#context-chart').innerHTML = data.charts.byContext.length
|
|
155
|
+
? bars(data.charts.byContext, true) : '<p class="empty">No work item matches this filter.</p>';
|
|
156
|
+
$('#approval-chart').innerHTML = data.charts.approvals.some((row) => row.count)
|
|
157
|
+
? bars(data.charts.approvals, false) : '<p class="empty">No work item needs approval yet.</p>';
|
|
158
|
+
const circumference = 2 * Math.PI * 62;
|
|
159
|
+
let offset = 0;
|
|
160
|
+
const arcs = data.charts.byKind.map((row) => {
|
|
161
|
+
const length = m.total ? circumference * row.count / m.total : 0;
|
|
162
|
+
const arc = '<circle cx="90" cy="90" r="62" fill="none" stroke="' + colors[row.id]
|
|
163
|
+
+ '" stroke-width="18" stroke-dasharray="' + length + ' ' + (circumference - length)
|
|
164
|
+
+ '" stroke-dashoffset="' + (-offset) + '" transform="rotate(-90 90 90)"></circle>';
|
|
165
|
+
offset += length;
|
|
166
|
+
return arc;
|
|
167
|
+
}).join('');
|
|
168
|
+
const legend = data.charts.byKind.map((row) => '<div class="kind-row"><i class="swatch ' + row.id
|
|
169
|
+
+ '"></i><span>' + escapeHtml(row.label) + '</span><span>' + row.count + ' <small>('
|
|
170
|
+
+ (m.total ? Math.round(100 * row.count / m.total) : 0) + '%)</small></span></div>').join('');
|
|
171
|
+
$('#kind-chart').innerHTML = '<svg class="donut" viewBox="0 0 180 180" role="img" aria-label="'
|
|
172
|
+
+ m.features + ' feature, ' + m.bugs + ' bug"><circle cx="90" cy="90" r="62" fill="none" stroke="#26354c" stroke-width="18"></circle>'
|
|
173
|
+
+ arcs + '<text x="90" y="91" class="donut-total">' + m.total
|
|
174
|
+
+ '</text><text x="90" y="111" class="donut-label">WORK ITEMS</text></svg><div class="kind-legend">' + legend + '</div>';
|
|
175
|
+
$('#task-chart').innerHTML = '<div class="task-line"><span>Tasks in execution</span><strong>'
|
|
176
|
+
+ m.tasks.done + '/' + m.tasks.total + '</strong></div><div class="track" role="img" aria-label="'
|
|
177
|
+
+ m.tasks.done + ' of ' + m.tasks.total + ' tasks done"><i class="segment feature" style="width:'
|
|
178
|
+
+ (m.tasks.completionRate ?? 0) + '%"></i></div><p class="caption">'
|
|
179
|
+
+ m.tasks.itemsTracked + ' items with tasks · ' + m.tasks.itemsUntracked + ' items without</p>';
|
|
180
|
+
$('#root').textContent = data.root;
|
|
181
|
+
$('#ts').textContent = new Date(data.updatedAt).toLocaleTimeString();
|
|
182
|
+
}
|
|
183
|
+
function query() {
|
|
184
|
+
const params = new URLSearchParams();
|
|
185
|
+
if ($('#context').value) params.set('context', $('#context').value);
|
|
186
|
+
if ($('#kind').value) params.set('kind', $('#kind').value);
|
|
187
|
+
return params.toString();
|
|
188
|
+
}
|
|
189
|
+
let contextOptionsKey = '';
|
|
190
|
+
function updateContexts(data) {
|
|
191
|
+
const selected = $('#context').value;
|
|
192
|
+
const options = data.availableContexts.map((context) => ({
|
|
193
|
+
value: context.id === null ? '__none__' : context.id, label: context.label
|
|
194
|
+
}));
|
|
195
|
+
if (selected && !options.some((option) => option.value === selected)) {
|
|
196
|
+
options.push({ value: selected, label: selected === '__none__' ? 'Unassigned' : selected });
|
|
197
|
+
}
|
|
198
|
+
const key = JSON.stringify(options);
|
|
199
|
+
if (key === contextOptionsKey) return;
|
|
200
|
+
contextOptionsKey = key;
|
|
201
|
+
$('#context').innerHTML = '<option value="">All contexts</option>' + options.map((option) =>
|
|
202
|
+
'<option value="' + escapeHtml(option.value) + '">' + escapeHtml(option.label) + '</option>').join('');
|
|
203
|
+
$('#context').value = selected;
|
|
204
|
+
}
|
|
205
|
+
let loading = false;
|
|
206
|
+
let queued = false;
|
|
207
|
+
async function refresh() {
|
|
208
|
+
if (loading) { queued = true; return; }
|
|
209
|
+
loading = true;
|
|
210
|
+
$('#refresh').disabled = true;
|
|
211
|
+
const requested = query();
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch('/api/data?' + requested, { cache: 'no-store' });
|
|
214
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
215
|
+
const data = await response.json();
|
|
216
|
+
if (requested !== query()) { queued = true; return; }
|
|
217
|
+
updateContexts(data);
|
|
218
|
+
render(data);
|
|
219
|
+
$('#error').hidden = true;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
$('#error').textContent = 'Could not load the metrics (' + (error instanceof Error ? error.message : String(error))
|
|
222
|
+
+ '). What is on screen may be stale. Check the server output, then refresh.';
|
|
223
|
+
$('#error').hidden = false;
|
|
224
|
+
} finally {
|
|
225
|
+
loading = false;
|
|
226
|
+
$('#refresh').disabled = false;
|
|
227
|
+
if (queued) { queued = false; refresh(); }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
$('#refresh').addEventListener('click', refresh);
|
|
231
|
+
$('#context').addEventListener('change', refresh);
|
|
232
|
+
$('#kind').addEventListener('change', refresh);
|
|
233
|
+
refresh();
|
|
234
|
+
setInterval(refresh, 5000);
|
|
235
|
+
</script>
|
|
236
|
+
</body>
|
|
237
|
+
</html>`;
|
|
238
|
+
}
|