@crewhaus/hangar-ui 0.5.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/README.md +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- package/package.json +36 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HM-179 — plugin panes, rendered.
|
|
3
|
+
*
|
|
4
|
+
* A pane is third-party markup, so it is never injected into this document.
|
|
5
|
+
* It goes into an `<iframe>` whose `sandbox` grants `allow-scripts` and
|
|
6
|
+
* NOTHING else: without `allow-same-origin` the frame has an opaque origin,
|
|
7
|
+
* so its script cannot reach this page's DOM, its sessionStorage (where the
|
|
8
|
+
* bearer token lives) or its history. The Content-Security-Policy the server
|
|
9
|
+
* computed from the plugin's own `net` allow-list rides along inside the
|
|
10
|
+
* document as a `<meta http-equiv>` line — fail-closed, so a plugin with no
|
|
11
|
+
* declared network permission gets `connect-src 'none'`.
|
|
12
|
+
*
|
|
13
|
+
* The frame is built with `srcdoc`, which is the ONE place in this console
|
|
14
|
+
* where a markup string reaches the DOM — and it is the one place where that
|
|
15
|
+
* is the safe option rather than the dangerous one: the string never becomes
|
|
16
|
+
* part of this document, only of a sandboxed one. Everything else on this
|
|
17
|
+
* screen is built with createElement like the rest of the app.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { api } from "../api.js";
|
|
21
|
+
import { clear, dot, el, emptyState, skeleton } from "../dom.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Assemble the sandboxed document: the plugin's own markup with the policy
|
|
25
|
+
* prepended. Pure, so the containment can be unit-tested — the assertions
|
|
26
|
+
* that matter are that the CSP is present and that the sandbox list never
|
|
27
|
+
* grows `allow-same-origin`.
|
|
28
|
+
*/
|
|
29
|
+
export function paneSrcdoc(doc) {
|
|
30
|
+
const csp = String(doc?.csp ?? "default-src 'none'");
|
|
31
|
+
const body = String(doc?.doc ?? "");
|
|
32
|
+
const meta = `<meta http-equiv="Content-Security-Policy" content="${csp.replace(/"/g, """)}">`;
|
|
33
|
+
return `<!doctype html><html><head><meta charset="utf-8">${meta}</head><body>${body}</body></html>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The sandbox token list for a pane frame. Never `allow-same-origin`. */
|
|
37
|
+
export function paneSandbox(doc) {
|
|
38
|
+
const declared = String(doc?.sandbox ?? "allow-scripts");
|
|
39
|
+
const tokens = declared.split(/\s+/).filter((t) => t !== "" && t !== "allow-same-origin");
|
|
40
|
+
return tokens.length > 0 ? tokens.join(" ") : "allow-scripts";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function renderPanes(root, ctx) {
|
|
44
|
+
clear(root).appendChild(skeleton(4));
|
|
45
|
+
let payload = null;
|
|
46
|
+
try {
|
|
47
|
+
payload = await api.panes(ctx.id);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
clear(root).appendChild(
|
|
50
|
+
el("div", { class: "card error-card" }, [
|
|
51
|
+
el("h2", { text: "Panes unavailable" }),
|
|
52
|
+
el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
clear(root);
|
|
58
|
+
const panes = Array.isArray(payload?.panes) ? payload.panes : [];
|
|
59
|
+
const observers = Array.isArray(payload?.traceObservers) ? payload.traceObservers : [];
|
|
60
|
+
|
|
61
|
+
root.appendChild(
|
|
62
|
+
el("section", { class: "card" }, [
|
|
63
|
+
el("h3", { class: "card-title", text: "Trace observers" }),
|
|
64
|
+
observers.length === 0
|
|
65
|
+
? emptyState(
|
|
66
|
+
"No plugin observes this harness's trace events",
|
|
67
|
+
"crewhaus plugins install <name>",
|
|
68
|
+
)
|
|
69
|
+
: el(
|
|
70
|
+
"div",
|
|
71
|
+
{ class: "safety-strip" },
|
|
72
|
+
observers.map((name) => el("span", { class: "chip", text: String(name) })),
|
|
73
|
+
),
|
|
74
|
+
el("p", {
|
|
75
|
+
class: "muted small",
|
|
76
|
+
text: "A plugin observes a harness only when its filesystem read permission covers that harness's directory — the same fail-closed evaluator the loader uses.",
|
|
77
|
+
}),
|
|
78
|
+
]),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (panes.length === 0) {
|
|
82
|
+
root.appendChild(
|
|
83
|
+
emptyState("No plugin pane applies to this harness", "crewhaus plugins install <name>"),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
for (const pane of panes) {
|
|
87
|
+
root.appendChild(await paneCard(pane));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function paneCard(pane) {
|
|
92
|
+
const card = el("section", { class: "card" }, [
|
|
93
|
+
el("div", { class: "rollup" }, [
|
|
94
|
+
el("h3", { class: "card-title", text: String(pane?.title ?? pane?.id ?? "pane") }),
|
|
95
|
+
el("span", { class: "chip", text: String(pane?.plugin ?? "") }),
|
|
96
|
+
]),
|
|
97
|
+
]);
|
|
98
|
+
let doc = null;
|
|
99
|
+
try {
|
|
100
|
+
doc = await api.pluginPane(String(pane?.plugin ?? ""), String(pane?.id ?? ""));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
card.appendChild(
|
|
103
|
+
el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
|
|
104
|
+
);
|
|
105
|
+
return card;
|
|
106
|
+
}
|
|
107
|
+
if (doc === null) {
|
|
108
|
+
card.appendChild(emptyState("This pane's document is missing", "crewhaus plugins list"));
|
|
109
|
+
return card;
|
|
110
|
+
}
|
|
111
|
+
const frame = el("iframe", {
|
|
112
|
+
class: "pane-frame",
|
|
113
|
+
title: String(pane?.title ?? "plugin pane"),
|
|
114
|
+
sandbox: paneSandbox(doc),
|
|
115
|
+
referrerpolicy: "no-referrer",
|
|
116
|
+
loading: "lazy",
|
|
117
|
+
});
|
|
118
|
+
// `srcdoc` — see the module docblock: the string becomes the SANDBOXED
|
|
119
|
+
// document, never part of this one.
|
|
120
|
+
frame.setAttribute("srcdoc", paneSrcdoc(doc));
|
|
121
|
+
card.appendChild(frame);
|
|
122
|
+
card.appendChild(
|
|
123
|
+
el("div", { class: "muted small" }, [
|
|
124
|
+
dot("ok", "sandboxed (opaque origin)"),
|
|
125
|
+
el("code", { class: "mono", text: String(doc.csp ?? "") }),
|
|
126
|
+
]),
|
|
127
|
+
);
|
|
128
|
+
if (doc.truncated === true) {
|
|
129
|
+
card.appendChild(dot("warn", "the pane document was capped — it is shown truncated"));
|
|
130
|
+
}
|
|
131
|
+
return card;
|
|
132
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process card and the Start / Stop / Restart / Drain action bar — the
|
|
3
|
+
* console's first real verbs.
|
|
4
|
+
*
|
|
5
|
+
* The whole screen is built around one idea: a refusal is information. A
|
|
6
|
+
* start that preflight blocks opens a modal listing every finding with its
|
|
7
|
+
* remediation (and offers "Start anyway" only when every finding is
|
|
8
|
+
* acknowledgeable); a spawn plan that cannot be built renders its remedy as
|
|
9
|
+
* a BUTTON; a bundle says whether its freshness verdict was hash-exact or a
|
|
10
|
+
* file-time approximation. Nothing here is a bare error toast.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { api } from "../api.js";
|
|
14
|
+
import { clear, collapsible, dot, el, jsonPre, toast } from "../dom.js";
|
|
15
|
+
import { hrefHarness } from "../router.js";
|
|
16
|
+
import { procRow, procWriteOutcome, refusalModel } from "../supervision.js";
|
|
17
|
+
import { actionTwin, cliTwin, gatedBtn, refusalModal, runAction } from "./control.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Report what a Stop / Drain actually did, then reload.
|
|
21
|
+
*
|
|
22
|
+
* A 200 is not the same as a stop. The supervisor answers `not-adopted` when
|
|
23
|
+
* it held no pid while a live runfile existed — a daemon IS running, this
|
|
24
|
+
* manager just never adopted it, and nothing was signalled. Reloading
|
|
25
|
+
* silently there would paint the row from a snapshot that agrees the daemon
|
|
26
|
+
* is "stopped", which is precisely the lie this reports instead.
|
|
27
|
+
*/
|
|
28
|
+
function reportProcWrite(res, label, reload) {
|
|
29
|
+
const outcome = procWriteOutcome(res, label);
|
|
30
|
+
if (outcome.message !== null) toast(outcome.message, outcome.tone);
|
|
31
|
+
reload();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start (or restart) a harness, routing the typed 409 to the refusal modal.
|
|
36
|
+
* `opts` carries `{ force, acknowledge }` on the second pass.
|
|
37
|
+
*/
|
|
38
|
+
export async function startProc(id, dir, opts, restart, reload) {
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = restart ? await api.procRestart(id, opts) : await api.procStart(id, opts);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
toast(`Start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (res.ok) {
|
|
47
|
+
reload();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (res.status !== 409) {
|
|
51
|
+
toast(`Start failed: ${res.body?.message ?? `HTTP ${res.status}`}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const model = refusalModel(res.body);
|
|
55
|
+
refusalModal(model, {
|
|
56
|
+
dir,
|
|
57
|
+
onForce: (acknowledge) => {
|
|
58
|
+
// `force` waves every FORCEABLE finding through; the id list keeps the
|
|
59
|
+
// audit trail honest about which ones an operator accepted.
|
|
60
|
+
startProc(id, dir, { force: true, acknowledge }, restart, reload);
|
|
61
|
+
},
|
|
62
|
+
onRemedy: async (kind) => {
|
|
63
|
+
try {
|
|
64
|
+
await api.submitJob(id, kind);
|
|
65
|
+
toast(`Queued ${kind} — watch it in the job queue`, "info");
|
|
66
|
+
reload();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
toast(`Could not queue ${kind}: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Start / Stop / Restart / Drain, each disabled with its reason. */
|
|
75
|
+
export function procActionBar(id, dir, row, reload) {
|
|
76
|
+
const bar = el("div", { class: "proc-actions" });
|
|
77
|
+
const twinHost = el("div", { class: "proc-twin" });
|
|
78
|
+
const showTwin = (action) => {
|
|
79
|
+
clear(twinHost).appendChild(actionTwin(action, { dir }) ?? el("span"));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const startBtn = gatedBtn(
|
|
83
|
+
"Start",
|
|
84
|
+
row.actions.start,
|
|
85
|
+
() => startProc(id, dir, {}, false, reload),
|
|
86
|
+
"btn btn-primary",
|
|
87
|
+
);
|
|
88
|
+
const stopBtn = gatedBtn("Stop", row.actions.stop, () => {
|
|
89
|
+
runAction(
|
|
90
|
+
stopBtn,
|
|
91
|
+
"Stop",
|
|
92
|
+
() => api.procStop(id),
|
|
93
|
+
(res) => reportProcWrite(res, "Stop", reload),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
const restartBtn = gatedBtn("Restart", row.actions.restart, () =>
|
|
97
|
+
startProc(id, dir, {}, true, reload),
|
|
98
|
+
);
|
|
99
|
+
const drainBtn = gatedBtn("Drain", row.actions.drain, () => {
|
|
100
|
+
// A drain has three endings — drained, drained by SIGTERM because no
|
|
101
|
+
// control plane answered, and nothing-was-signalled — and only the first
|
|
102
|
+
// deserves silence.
|
|
103
|
+
runAction(
|
|
104
|
+
drainBtn,
|
|
105
|
+
"Drain",
|
|
106
|
+
() => api.procDrain(id),
|
|
107
|
+
(res) => reportProcWrite(res, "Drain", reload),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const [btn, action] of [
|
|
112
|
+
[startBtn, "start"],
|
|
113
|
+
[stopBtn, "stop"],
|
|
114
|
+
[restartBtn, "restart"],
|
|
115
|
+
[drainBtn, "drain"],
|
|
116
|
+
]) {
|
|
117
|
+
btn.addEventListener("mouseenter", () => showTwin(action));
|
|
118
|
+
btn.addEventListener("focus", () => showTwin(action));
|
|
119
|
+
bar.appendChild(btn);
|
|
120
|
+
}
|
|
121
|
+
showTwin(row.state === "running" ? "stop" : "start");
|
|
122
|
+
|
|
123
|
+
// A plan that cannot be built has an obvious next step — offer it.
|
|
124
|
+
const planError = row.launch.error;
|
|
125
|
+
if (planError !== null && planError.action !== null) {
|
|
126
|
+
const remedy = el("button", {
|
|
127
|
+
class: "btn",
|
|
128
|
+
type: "button",
|
|
129
|
+
text: planError.action.label,
|
|
130
|
+
title: planError.action.hint,
|
|
131
|
+
});
|
|
132
|
+
if (planError.action.jobKind === null) {
|
|
133
|
+
remedy.disabled = true;
|
|
134
|
+
} else {
|
|
135
|
+
remedy.addEventListener("click", () => {
|
|
136
|
+
runAction(
|
|
137
|
+
remedy,
|
|
138
|
+
planError.action.label,
|
|
139
|
+
() => api.submitJob(id, planError.action.jobKind),
|
|
140
|
+
() => {
|
|
141
|
+
toast(`Queued ${planError.action.jobKind}`, "info");
|
|
142
|
+
reload();
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
bar.appendChild(remedy);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const reasons = el("div", { class: "proc-reasons" });
|
|
151
|
+
for (const [label, gate] of Object.entries(row.actions)) {
|
|
152
|
+
if (gate.enabled || !gate.reason) continue;
|
|
153
|
+
reasons.appendChild(el("div", { class: "muted gated-why", text: `${label}: ${gate.reason}` }));
|
|
154
|
+
}
|
|
155
|
+
return el("div", { class: "proc-bar" }, [bar, twinHost, reasons]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The full process card: state, identity, restarts, last exit, launch plan. */
|
|
159
|
+
export function procCard(payload, ctx, reload) {
|
|
160
|
+
const nowMs = Date.now();
|
|
161
|
+
const row = procRow(payload, nowMs);
|
|
162
|
+
const dir = ctx.dir ?? "";
|
|
163
|
+
const kv = (k, v) =>
|
|
164
|
+
el("div", { class: "kv" }, [
|
|
165
|
+
el("span", { class: "kv-k", text: k }),
|
|
166
|
+
typeof v === "string"
|
|
167
|
+
? el("span", { class: "kv-v", text: v })
|
|
168
|
+
: el("span", { class: "kv-v" }, [v]),
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const facts = el("div", { class: "proc-facts" }, [
|
|
172
|
+
kv("state", el("span", null, [dot(row.pill.dot, row.pill.label)])),
|
|
173
|
+
row.pill.note !== "" ? kv("", el("span", { class: "muted", text: row.pill.note })) : null,
|
|
174
|
+
kv("pid", row.pid === null ? "—" : String(row.pid)),
|
|
175
|
+
kv("uptime", row.uptime),
|
|
176
|
+
kv("run", row.runId === null ? "—" : row.runId),
|
|
177
|
+
kv("run class", row.runClass || "—"),
|
|
178
|
+
kv(
|
|
179
|
+
"restarts",
|
|
180
|
+
el("span", null, [
|
|
181
|
+
el("span", { text: String(row.restartsInWindow) }),
|
|
182
|
+
row.restart !== null
|
|
183
|
+
? el("span", { class: "chip chip-warn", text: row.restart.label })
|
|
184
|
+
: null,
|
|
185
|
+
]),
|
|
186
|
+
),
|
|
187
|
+
kv(
|
|
188
|
+
"control",
|
|
189
|
+
el("span", null, [
|
|
190
|
+
dot(row.control.dot, row.control.label),
|
|
191
|
+
row.control.reason !== null
|
|
192
|
+
? el("div", { class: "muted reason", text: row.control.reason })
|
|
193
|
+
: null,
|
|
194
|
+
]),
|
|
195
|
+
),
|
|
196
|
+
kv(
|
|
197
|
+
"bundle",
|
|
198
|
+
el("span", null, [
|
|
199
|
+
dot(row.bundle.dot, row.bundle.label),
|
|
200
|
+
el("div", { class: "muted reason", text: row.bundle.precision }),
|
|
201
|
+
row.bundle.compiledWith !== null
|
|
202
|
+
? el("div", { class: "muted reason", text: `compiled with ${row.bundle.compiledWith}` })
|
|
203
|
+
: null,
|
|
204
|
+
]),
|
|
205
|
+
),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const children = [facts, procActionBar(ctx.id, dir, row, reload)];
|
|
209
|
+
|
|
210
|
+
if (row.lastExit !== null) {
|
|
211
|
+
children.push(
|
|
212
|
+
el("div", { class: "exit-banner" }, [
|
|
213
|
+
dot(row.lastExit.dot, row.lastExit.title),
|
|
214
|
+
row.lastExit.classLabel !== null
|
|
215
|
+
? el("span", { class: "chip chip-warn", text: row.lastExit.classLabel })
|
|
216
|
+
: null,
|
|
217
|
+
row.lastExit.exitCode !== null
|
|
218
|
+
? el("span", { class: "chip", text: `exit ${row.lastExit.exitCode}` })
|
|
219
|
+
: null,
|
|
220
|
+
row.lastExit.fromLedger ? el("span", { class: "chip", text: "from the run ledger" }) : null,
|
|
221
|
+
row.lastExit.remediation !== null
|
|
222
|
+
? el("div", { class: "muted reason", text: row.lastExit.remediation })
|
|
223
|
+
: null,
|
|
224
|
+
]),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (row.forensics !== null) {
|
|
228
|
+
const tail = Array.isArray(row.forensics.tail) ? row.forensics.tail : [];
|
|
229
|
+
children.push(
|
|
230
|
+
collapsible(
|
|
231
|
+
[el("span", { class: "muted", text: "forensics from the last failure" })],
|
|
232
|
+
[
|
|
233
|
+
row.forensics.lastRunFailed
|
|
234
|
+
? jsonPre(row.forensics.lastRunFailed)
|
|
235
|
+
: el("pre", { class: "prose-pre", text: tail.join("\n") || "no captured tail" }),
|
|
236
|
+
],
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// What Start would actually spawn — the plan preview, never the env (which
|
|
242
|
+
// carries the control token).
|
|
243
|
+
if (row.launch.cliTwin !== null) {
|
|
244
|
+
children.push(
|
|
245
|
+
collapsible(
|
|
246
|
+
[
|
|
247
|
+
el("span", { class: "muted", text: `launch: ${row.launch.mode ?? "?"}` }),
|
|
248
|
+
row.launch.canResume ? el("span", { class: "chip", text: "resumable" }) : null,
|
|
249
|
+
row.launch.detached ? el("span", { class: "chip", text: "detached" }) : null,
|
|
250
|
+
],
|
|
251
|
+
[
|
|
252
|
+
cliTwin(row.launch.cliTwin),
|
|
253
|
+
row.launch.envFiles.length > 0
|
|
254
|
+
? el("div", {
|
|
255
|
+
class: "muted reason",
|
|
256
|
+
text: `env chain: ${row.launch.envFiles.join(", ")}`,
|
|
257
|
+
})
|
|
258
|
+
: null,
|
|
259
|
+
],
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
} else if (row.launch.error !== null) {
|
|
263
|
+
children.push(el("p", { class: "muted", text: row.launch.error.message }));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
children.push(
|
|
267
|
+
el("div", { class: "proc-links" }, [
|
|
268
|
+
el("a", { href: hrefHarness(ctx.id, "runs"), text: "run history →" }),
|
|
269
|
+
el("a", { href: hrefHarness(ctx.id, "schedulers"), text: "schedulers →" }),
|
|
270
|
+
]),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return el("section", { class: "card ov-card ov-wide" }, [
|
|
274
|
+
el("h3", { class: "card-title" }, [
|
|
275
|
+
el("span", { text: "Process" }),
|
|
276
|
+
el("span", { class: "muted card-sub", text: row.target }),
|
|
277
|
+
]),
|
|
278
|
+
...children,
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review queue — `.crewhaus/review/queue.jsonl` folded across the fleet,
|
|
3
|
+
* with the same triage ergonomics as the approvals inbox (j/k, one-key
|
|
4
|
+
* verdicts, `.` row menu, `?` bindings).
|
|
5
|
+
*
|
|
6
|
+
* THE VERDICTS DIFFER BY ITEM. An item that points at a session TURN can be
|
|
7
|
+
* ADJUDICATED — up/down take the same path `crewhaus rate --adjudicate`
|
|
8
|
+
* takes, settling the disagreement at its source so the next distill does
|
|
9
|
+
* not re-open it. An item with no turn (an eval abstention, a quarantine
|
|
10
|
+
* pointer) can only record pass/fail on the item itself, so the thumbs are
|
|
11
|
+
* not offered there: the server would 409, and a button that always fails is
|
|
12
|
+
* worse than one that is not there.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { api } from "../api.js";
|
|
16
|
+
import { clear, dot, el, emptyState, jsonPre, skeleton, toast } from "../dom.js";
|
|
17
|
+
import { hrefHarness } from "../router.js";
|
|
18
|
+
import { REVIEW_KEYS, reviewRow } from "../supervision.js";
|
|
19
|
+
import { actionTwin } from "./control.js";
|
|
20
|
+
import { mountTriage } from "./inbox.js";
|
|
21
|
+
|
|
22
|
+
export async function renderReview(root) {
|
|
23
|
+
clear(root).appendChild(skeleton(6));
|
|
24
|
+
await draw(root, false);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function draw(host, showResolved) {
|
|
28
|
+
let payload;
|
|
29
|
+
try {
|
|
30
|
+
payload = await api.review(showResolved);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
clear(host).appendChild(
|
|
33
|
+
el("div", { class: "card error-card" }, [
|
|
34
|
+
el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
|
|
35
|
+
]),
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
clear(host);
|
|
40
|
+
const nowMs = Date.now();
|
|
41
|
+
const rows = (payload && Array.isArray(payload.items) ? payload.items : []).map((r) =>
|
|
42
|
+
reviewRow(r, nowMs),
|
|
43
|
+
);
|
|
44
|
+
const open = payload && typeof payload.open === "number" ? payload.open : 0;
|
|
45
|
+
const reload = () => draw(host, showResolved);
|
|
46
|
+
|
|
47
|
+
host.appendChild(
|
|
48
|
+
el("div", { class: "toolbar" }, [
|
|
49
|
+
el("span", { class: "rollup", text: `${open} open · ${rows.length} shown` }),
|
|
50
|
+
el("span", { class: "spacer" }),
|
|
51
|
+
el("button", {
|
|
52
|
+
class: `btn btn-ghost${showResolved ? " active" : ""}`,
|
|
53
|
+
type: "button",
|
|
54
|
+
text: showResolved ? "Showing resolved too" : "Show resolved",
|
|
55
|
+
onClick: () => draw(host, !showResolved),
|
|
56
|
+
}),
|
|
57
|
+
]),
|
|
58
|
+
);
|
|
59
|
+
if (rows.length === 0) {
|
|
60
|
+
host.appendChild(
|
|
61
|
+
emptyState(
|
|
62
|
+
showResolved ? "No review items on this fleet" : "Nothing waiting for review",
|
|
63
|
+
"crewhaus review list",
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
mountTriage(host, rows, {
|
|
69
|
+
keys: REVIEW_KEYS,
|
|
70
|
+
reload,
|
|
71
|
+
render: reviewCard,
|
|
72
|
+
perform: adjudicate,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function adjudicate(row, verdict, reload) {
|
|
77
|
+
if (!row.verdicts.includes(verdict)) {
|
|
78
|
+
toast(`${verdict} does not apply here — ${row.verdictNote}`, "info");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let result;
|
|
82
|
+
try {
|
|
83
|
+
result = await api.adjudicateReview(row.harnessId, row.id, verdict);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
toast(`Verdict failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
toast(
|
|
89
|
+
result && result.adjudicated === true
|
|
90
|
+
? `Adjudicated the rated turn (${verdict})`
|
|
91
|
+
: `Recorded ${verdict} on the item`,
|
|
92
|
+
"info",
|
|
93
|
+
);
|
|
94
|
+
reload();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function reviewCard(row, index, state, redraw, reload) {
|
|
98
|
+
const selected = state.index === index;
|
|
99
|
+
const verdictBtn = (verdict) => {
|
|
100
|
+
const btn = el("button", {
|
|
101
|
+
class: `btn${verdict === "up" || verdict === "pass" ? " btn-primary" : ""}`,
|
|
102
|
+
type: "button",
|
|
103
|
+
text: verdict,
|
|
104
|
+
title: row.open ? `record ${verdict}` : `already ${row.status}`,
|
|
105
|
+
});
|
|
106
|
+
if (!row.open) btn.disabled = true;
|
|
107
|
+
else btn.addEventListener("click", () => adjudicate(row, verdict, reload));
|
|
108
|
+
return btn;
|
|
109
|
+
};
|
|
110
|
+
return el(
|
|
111
|
+
"section",
|
|
112
|
+
{
|
|
113
|
+
class: `card inbox-card${selected ? " selected" : ""}`,
|
|
114
|
+
"aria-current": selected ? "true" : null,
|
|
115
|
+
onClick: () => {
|
|
116
|
+
if (!selected) redraw({ ...state, index });
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
[
|
|
120
|
+
el("div", { class: "inbox-head" }, [
|
|
121
|
+
dot(row.dot, row.status),
|
|
122
|
+
el("span", { class: "chip", text: row.kind }),
|
|
123
|
+
el("a", { class: "name-link", href: hrefHarness(row.harnessId), text: row.specName }),
|
|
124
|
+
row.adjudicable
|
|
125
|
+
? el("span", { class: "chip chip-group", text: "adjudicable" })
|
|
126
|
+
: el("span", { class: "chip", text: "item verdict only" }),
|
|
127
|
+
el("span", { class: "muted", text: row.age }),
|
|
128
|
+
]),
|
|
129
|
+
row.context !== "" ? el("pre", { class: "prose-pre", text: row.context }) : null,
|
|
130
|
+
row.sessionId !== null
|
|
131
|
+
? el("div", { class: "muted reason" }, [
|
|
132
|
+
el("span", { text: "turn " }),
|
|
133
|
+
el("span", { class: "mono", text: String(row.turn ?? "?") }),
|
|
134
|
+
el("span", { text: " of " }),
|
|
135
|
+
el("a", {
|
|
136
|
+
class: "mono",
|
|
137
|
+
href: hrefHarness(row.harnessId, "sessions", row.sessionId),
|
|
138
|
+
text: row.sessionId,
|
|
139
|
+
}),
|
|
140
|
+
])
|
|
141
|
+
: null,
|
|
142
|
+
el("div", { class: "muted reason", text: row.verdictNote }),
|
|
143
|
+
el("div", { class: "inbox-actions" }, row.verdicts.map(verdictBtn)),
|
|
144
|
+
el("div", { class: "inbox-twins" }, [
|
|
145
|
+
row.adjudicable
|
|
146
|
+
? actionTwin("adjudicate", {
|
|
147
|
+
sessionId: row.sessionId,
|
|
148
|
+
turn: row.turn ?? 0,
|
|
149
|
+
verdict: "up",
|
|
150
|
+
})
|
|
151
|
+
: null,
|
|
152
|
+
actionTwin("review", { id: row.id }),
|
|
153
|
+
]),
|
|
154
|
+
Object.keys(row.sourceRef).length > 0
|
|
155
|
+
? el("details", { class: "fold" }, [
|
|
156
|
+
el("summary", { class: "fold-summary" }, [
|
|
157
|
+
el("span", { class: "muted", text: "source reference" }),
|
|
158
|
+
]),
|
|
159
|
+
el("div", { class: "fold-body" }, [jsonPre(row.sourceRef)]),
|
|
160
|
+
])
|
|
161
|
+
: null,
|
|
162
|
+
],
|
|
163
|
+
);
|
|
164
|
+
}
|