@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,925 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security & audit — the audit chain, the egress review console, the PII
|
|
3
|
+
* tuner, the justification console, the security corpus and sandbox checks,
|
|
4
|
+
* the onchain safety panels, compliance evidence, retention, and the SLO
|
|
5
|
+
* monitor.
|
|
6
|
+
*
|
|
7
|
+
* Honesty rules this screen is responsible for:
|
|
8
|
+
* - `audit verify` reports its DESIGNED limits (`anchorChecked` /
|
|
9
|
+
* `externalAnchorChecked: false`) as facts, not as failures, and the
|
|
10
|
+
* raw chain files are never shown — only rendered records.
|
|
11
|
+
* - an egress record carries LINEAGE, not the outbound payload. The
|
|
12
|
+
* console says so rather than rendering an empty body field that reads
|
|
13
|
+
* like data loss.
|
|
14
|
+
* - retention sweep and purge run `--dry-run` FIRST and show the plan; the
|
|
15
|
+
* real run is a second, typed-confirm gesture, and the plan names what
|
|
16
|
+
* the pins saved.
|
|
17
|
+
* - `transaction_policy` is human-owned spec, so its tuner previews and
|
|
18
|
+
* hands off to the spec write path rather than writing behind it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { api } from "../api.js";
|
|
22
|
+
import { clear, collapsible, dot, el, jsonPre, skeleton, toast } from "../dom.js";
|
|
23
|
+
import { fmtPct, fmtRelativeTime } from "../util.js";
|
|
24
|
+
import { m3, m3Card, m3Empty } from "./creds.js";
|
|
25
|
+
|
|
26
|
+
export async function renderSecurity(root, ctx) {
|
|
27
|
+
clear(root).appendChild(skeleton(8));
|
|
28
|
+
const [
|
|
29
|
+
auditAnswer,
|
|
30
|
+
egressAnswer,
|
|
31
|
+
piiAnswer,
|
|
32
|
+
justificationAnswer,
|
|
33
|
+
corpusAnswer,
|
|
34
|
+
sandboxAnswer,
|
|
35
|
+
onchainAnswer,
|
|
36
|
+
sentinelAnswer,
|
|
37
|
+
complianceAnswer,
|
|
38
|
+
retentionAnswer,
|
|
39
|
+
sloAnswer,
|
|
40
|
+
] = await Promise.all([
|
|
41
|
+
m3(api.audit({ id: ctx.id })),
|
|
42
|
+
m3(api.egress({ id: ctx.id })),
|
|
43
|
+
m3(api.pii({ id: ctx.id })),
|
|
44
|
+
m3(api.justification({ id: ctx.id })),
|
|
45
|
+
m3(api.securityCorpus({ id: ctx.id })),
|
|
46
|
+
m3(api.sandboxDoctor({ id: ctx.id })),
|
|
47
|
+
m3(api.onchain({ id: ctx.id })),
|
|
48
|
+
m3(api.onchainSentinel({ id: ctx.id })),
|
|
49
|
+
m3(api.compliance({ id: ctx.id })),
|
|
50
|
+
m3(api.retention({ id: ctx.id })),
|
|
51
|
+
m3(api.slo({ id: ctx.id })),
|
|
52
|
+
]);
|
|
53
|
+
clear(root);
|
|
54
|
+
const reload = () => renderSecurity(root, ctx);
|
|
55
|
+
|
|
56
|
+
root.appendChild(m3Card("Audit chain", auditAnswer, (body) => auditCard(body, ctx)));
|
|
57
|
+
root.appendChild(m3Card("Egress review", egressAnswer, (body) => egressCard(body, ctx, reload)));
|
|
58
|
+
root.appendChild(m3Card("SLO monitor", sloAnswer, (body) => sloCard(body)));
|
|
59
|
+
root.appendChild(
|
|
60
|
+
m3Card("Justification", justificationAnswer, (body) => justificationCard(body, ctx)),
|
|
61
|
+
);
|
|
62
|
+
root.appendChild(m3Card("PII policy", piiAnswer, (body) => piiCard(body, ctx, reload)));
|
|
63
|
+
root.appendChild(
|
|
64
|
+
m3Card("Security corpus", corpusAnswer, (body) => corpusCard(body, ctx, reload)),
|
|
65
|
+
);
|
|
66
|
+
root.appendChild(m3Card("Sandbox", sandboxAnswer, (body) => sandboxCard(body)));
|
|
67
|
+
root.appendChild(
|
|
68
|
+
m3Card("Onchain safety", onchainAnswer, (body) =>
|
|
69
|
+
onchainCard(body, sentinelAnswer.ok ? sentinelAnswer.body : null, ctx),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
root.appendChild(
|
|
73
|
+
m3Card("Compliance evidence", complianceAnswer, (body) => complianceCard(body, ctx, reload)),
|
|
74
|
+
);
|
|
75
|
+
root.appendChild(m3Card("Retention", retentionAnswer, (body) => retentionCard(body, ctx)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Audit
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function auditCard(body, ctx) {
|
|
83
|
+
const records = Array.isArray(body.records) ? body.records : [];
|
|
84
|
+
const kinds = Array.isArray(body.kinds) ? body.kinds : [];
|
|
85
|
+
const encryption = body.encryption && typeof body.encryption === "object" ? body.encryption : {};
|
|
86
|
+
const verifyState = el("div", { class: "control-state" });
|
|
87
|
+
|
|
88
|
+
const verifyBtn = el("button", { class: "btn", type: "button", text: "Verify chain" });
|
|
89
|
+
verifyBtn.addEventListener("click", async () => {
|
|
90
|
+
verifyBtn.disabled = true;
|
|
91
|
+
const answer = await m3(api.auditVerify({ id: ctx.id }, {}));
|
|
92
|
+
verifyBtn.disabled = false;
|
|
93
|
+
if (!answer.ok) {
|
|
94
|
+
verifyState.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const result = answer.body;
|
|
98
|
+
const limitations = Array.isArray(result.limitations) ? result.limitations : [];
|
|
99
|
+
verifyState.replaceChildren(
|
|
100
|
+
result.ok === true
|
|
101
|
+
? dot("ok", `chain intact — ${String(result.recordsChecked)} record(s) checked`)
|
|
102
|
+
: dot("bad", `chain broken: ${String(result.reason ?? "")}`),
|
|
103
|
+
// The two anchor flags are FACTS about what the verifier does, not
|
|
104
|
+
// failures — so they render as notes beside a green light, never as a
|
|
105
|
+
// downgrade of it.
|
|
106
|
+
...limitations.map((line) => el("div", { class: "muted reason", text: String(line) })),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const nodes = [
|
|
111
|
+
el("div", { class: "rollup" }, [
|
|
112
|
+
dot(body.present === true ? "ok" : "off", `${records.length} record(s) rendered`),
|
|
113
|
+
encryption.on === true ? el("span", { class: "chip chip-warn", text: "encrypted" }) : null,
|
|
114
|
+
body.truncated === true ? el("span", { class: "chip", text: "truncated" }) : null,
|
|
115
|
+
Number(body.tornLines) > 0
|
|
116
|
+
? el("span", { class: "chip", text: `${Number(body.tornLines)} torn line(s)` })
|
|
117
|
+
: null,
|
|
118
|
+
]),
|
|
119
|
+
el("p", { class: "muted", text: String(body.rawFilesNote ?? "") }),
|
|
120
|
+
encryption.on === true && encryption.note
|
|
121
|
+
? el("p", { class: "muted", text: String(encryption.note) })
|
|
122
|
+
: null,
|
|
123
|
+
el("div", { class: "control-action" }, [
|
|
124
|
+
el("div", { class: "control-row" }, [verifyBtn]),
|
|
125
|
+
verifyState,
|
|
126
|
+
]),
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
if (kinds.length > 0) {
|
|
130
|
+
nodes.push(
|
|
131
|
+
el(
|
|
132
|
+
"div",
|
|
133
|
+
{ class: "chip-group" },
|
|
134
|
+
kinds.map((entry) =>
|
|
135
|
+
el("span", { class: "chip", text: `${String(entry.kind)} ${String(entry.count)}` }),
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (records.length === 0) {
|
|
141
|
+
nodes.push(m3Empty(body, "No audit records yet"));
|
|
142
|
+
return nodes;
|
|
143
|
+
}
|
|
144
|
+
const tbody = el("tbody");
|
|
145
|
+
for (const record of records.slice(-100).reverse()) {
|
|
146
|
+
tbody.appendChild(
|
|
147
|
+
el("tr", null, [
|
|
148
|
+
el("td", { class: "mono", text: String(record.seq) }),
|
|
149
|
+
el("td", {
|
|
150
|
+
class: "muted",
|
|
151
|
+
text: record.ts ? fmtRelativeTime(record.ts, Date.now()) : "—",
|
|
152
|
+
}),
|
|
153
|
+
el("td", { class: "mono", text: String(record.kind) }),
|
|
154
|
+
el("td", null, [
|
|
155
|
+
record.hashOk ? dot("ok", "body matches hash") : dot("warn", "hash mismatch"),
|
|
156
|
+
]),
|
|
157
|
+
el("td", null, [
|
|
158
|
+
record.encrypted
|
|
159
|
+
? el("span", { class: "muted", text: "sealed" })
|
|
160
|
+
: collapsible(
|
|
161
|
+
[el("span", { class: "muted", text: "payload" })],
|
|
162
|
+
[jsonPre(record.payload)],
|
|
163
|
+
),
|
|
164
|
+
]),
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
nodes.push(
|
|
169
|
+
el("div", { class: "table-scroll" }, [
|
|
170
|
+
el("table", { class: "fleet" }, [
|
|
171
|
+
el(
|
|
172
|
+
"thead",
|
|
173
|
+
null,
|
|
174
|
+
el(
|
|
175
|
+
"tr",
|
|
176
|
+
null,
|
|
177
|
+
["Seq", "When", "Kind", "Integrity", "Payload"].map((h) => el("th", { text: h })),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
tbody,
|
|
181
|
+
]),
|
|
182
|
+
]),
|
|
183
|
+
);
|
|
184
|
+
return nodes;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Egress
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
function egressCard(body, ctx, reload) {
|
|
192
|
+
const decisions = Array.isArray(body.decisions) ? body.decisions : [];
|
|
193
|
+
const nodes = [
|
|
194
|
+
el("div", { class: "rollup" }, [
|
|
195
|
+
dot(Number(body.open) > 0 ? "warn" : "ok", `${Number(body.open) || 0} untriaged`),
|
|
196
|
+
el("span", { class: "chip", text: `${decisions.length} decision(s)` }),
|
|
197
|
+
]),
|
|
198
|
+
// The record's omission of the outbound body is DELIBERATE — saying so
|
|
199
|
+
// is the difference between "designed" and "broken".
|
|
200
|
+
el("p", { class: "muted", text: String(body.payloadNote ?? "") }),
|
|
201
|
+
];
|
|
202
|
+
if (decisions.length === 0) {
|
|
203
|
+
nodes.push(m3Empty(body, "No egress decisions recorded"));
|
|
204
|
+
return nodes;
|
|
205
|
+
}
|
|
206
|
+
const tbody = el("tbody");
|
|
207
|
+
for (const decision of decisions.slice(-100).reverse()) {
|
|
208
|
+
tbody.appendChild(
|
|
209
|
+
el("tr", null, [
|
|
210
|
+
el("td", { class: "mono", text: String(decision.decisionId) }),
|
|
211
|
+
el("td", {
|
|
212
|
+
class: "muted",
|
|
213
|
+
text: decision.ts ? fmtRelativeTime(decision.ts, Date.now()) : "—",
|
|
214
|
+
}),
|
|
215
|
+
el("td", { class: "mono", text: String(decision.sinkId ?? "—") }),
|
|
216
|
+
el("td", null, [
|
|
217
|
+
decision.verdict === "block"
|
|
218
|
+
? dot("bad", "block")
|
|
219
|
+
: dot("warn", String(decision.verdict ?? "—")),
|
|
220
|
+
]),
|
|
221
|
+
el("td", { class: "muted sub", text: (decision.origins || []).join(" → ") || "—" }),
|
|
222
|
+
el("td", { class: "cell-actions" }, [triageControl(decision, ctx, reload)]),
|
|
223
|
+
]),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
nodes.push(
|
|
227
|
+
el("div", { class: "table-scroll" }, [
|
|
228
|
+
el("table", { class: "fleet" }, [
|
|
229
|
+
el(
|
|
230
|
+
"thead",
|
|
231
|
+
null,
|
|
232
|
+
el(
|
|
233
|
+
"tr",
|
|
234
|
+
null,
|
|
235
|
+
["Id", "When", "Sink", "Verdict", "Lineage", "Triage"].map((h) =>
|
|
236
|
+
el("th", { text: h }),
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
tbody,
|
|
241
|
+
]),
|
|
242
|
+
]),
|
|
243
|
+
);
|
|
244
|
+
return nodes;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function triageControl(decision, ctx, reload) {
|
|
248
|
+
if (decision.triage) {
|
|
249
|
+
return el("span", { class: "muted" }, [
|
|
250
|
+
dot("ok", String(decision.triage.verdict)),
|
|
251
|
+
el("span", { text: ` by ${String(decision.triage.by ?? "?")}` }),
|
|
252
|
+
]);
|
|
253
|
+
}
|
|
254
|
+
const select = el("select", { class: "input", "aria-label": "triage verdict" }, [
|
|
255
|
+
el("option", { value: "acknowledge", text: "acknowledge" }),
|
|
256
|
+
el("option", { value: "allow", text: "allow" }),
|
|
257
|
+
el("option", { value: "block", text: "block" }),
|
|
258
|
+
el("option", { value: "needs-review", text: "needs-review" }),
|
|
259
|
+
]);
|
|
260
|
+
const note = el("input", { class: "input", type: "text", placeholder: "note (optional)" });
|
|
261
|
+
const save = el("button", { class: "btn btn-ghost", type: "button", text: "Record" });
|
|
262
|
+
save.addEventListener("click", async () => {
|
|
263
|
+
save.disabled = true;
|
|
264
|
+
const answer = await m3(
|
|
265
|
+
api.egressReview(
|
|
266
|
+
{ id: ctx.id, decisionId: String(decision.decisionId) },
|
|
267
|
+
{ verdict: select.value, ...(note.value !== "" ? { note: note.value } : {}) },
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
save.disabled = false;
|
|
271
|
+
if (!answer.ok || answer.body.recorded !== true) {
|
|
272
|
+
toast(String(answer.body.reason ?? answer.body.error ?? "could not record the triage"));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
reload();
|
|
276
|
+
});
|
|
277
|
+
return el("div", { class: "row-editor" }, [select, note, save]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// SLO
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
function sloCard(body) {
|
|
285
|
+
const targets = Array.isArray(body.targets) ? body.targets : [];
|
|
286
|
+
const alerts = Array.isArray(body.alerts) ? body.alerts : [];
|
|
287
|
+
const exporters = body.exporters && typeof body.exporters === "object" ? body.exporters : {};
|
|
288
|
+
const nodes = [
|
|
289
|
+
el("div", { class: "rollup" }, [
|
|
290
|
+
dot(
|
|
291
|
+
targets.some((target) => target.breached) ? "bad" : body.declared === true ? "ok" : "off",
|
|
292
|
+
body.declared === true
|
|
293
|
+
? targets.some((target) => target.breached)
|
|
294
|
+
? "a target is breached"
|
|
295
|
+
: "within every declared target"
|
|
296
|
+
: "no SLO declared",
|
|
297
|
+
),
|
|
298
|
+
el("span", { class: "chip", text: `ladder: ${(body.ladder || []).join(" → ")}` }),
|
|
299
|
+
el("span", { class: "chip", text: `state: ${String(body.ladderState ?? "idle")}` }),
|
|
300
|
+
typeof body.observedAt === "string"
|
|
301
|
+
? el("span", {
|
|
302
|
+
class: "asof",
|
|
303
|
+
title: body.observedAt,
|
|
304
|
+
text: `as of ${fmtRelativeTime(body.observedAt, Date.now())}`,
|
|
305
|
+
})
|
|
306
|
+
: null,
|
|
307
|
+
]),
|
|
308
|
+
el("p", { class: "muted", text: String(body.ladderNote ?? "") }),
|
|
309
|
+
];
|
|
310
|
+
if (targets.length === 0) {
|
|
311
|
+
nodes.push(m3Empty(body, "No SLO targets and no observations"));
|
|
312
|
+
} else {
|
|
313
|
+
const tbody = el("tbody");
|
|
314
|
+
for (const target of targets) {
|
|
315
|
+
tbody.appendChild(
|
|
316
|
+
el("tr", null, [
|
|
317
|
+
el("td", { class: "mono", text: String(target.metric) }),
|
|
318
|
+
el("td", { text: fmtTarget(target.threshold, target.unit) }),
|
|
319
|
+
el("td", { text: fmtTarget(target.observed, target.unit) }),
|
|
320
|
+
el("td", null, [
|
|
321
|
+
target.observed === null
|
|
322
|
+
? dot("off", "no observation")
|
|
323
|
+
: target.breached
|
|
324
|
+
? dot("bad", "breached")
|
|
325
|
+
: dot("ok", "within target"),
|
|
326
|
+
]),
|
|
327
|
+
]),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
nodes.push(
|
|
331
|
+
el("div", { class: "table-scroll" }, [
|
|
332
|
+
el("table", { class: "fleet" }, [
|
|
333
|
+
el(
|
|
334
|
+
"thead",
|
|
335
|
+
null,
|
|
336
|
+
el(
|
|
337
|
+
"tr",
|
|
338
|
+
null,
|
|
339
|
+
["Metric", "Target", "Observed", "State"].map((h) => el("th", { text: h })),
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
tbody,
|
|
343
|
+
]),
|
|
344
|
+
]),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
nodes.push(
|
|
348
|
+
el("div", { class: "rollup" }, [
|
|
349
|
+
exporters.otlpEndpointConfigured
|
|
350
|
+
? dot("ok", "OTLP endpoint set")
|
|
351
|
+
: dot("off", "no OTLP endpoint"),
|
|
352
|
+
exporters.metricsConfigured ? dot("ok", "metrics sink set") : dot("off", "no metrics sink"),
|
|
353
|
+
el("span", { class: "muted", text: String(exporters.note ?? "") }),
|
|
354
|
+
]),
|
|
355
|
+
);
|
|
356
|
+
if (alerts.length > 0) {
|
|
357
|
+
nodes.push(
|
|
358
|
+
collapsible(
|
|
359
|
+
[el("span", { class: "muted", text: `${alerts.length} alert_raised record(s)` })],
|
|
360
|
+
[
|
|
361
|
+
el(
|
|
362
|
+
"ul",
|
|
363
|
+
{ class: "check-list" },
|
|
364
|
+
alerts
|
|
365
|
+
.slice(-20)
|
|
366
|
+
.reverse()
|
|
367
|
+
.map((alert) =>
|
|
368
|
+
el("li", { class: "check" }, [
|
|
369
|
+
dot("warn", String(alert.metric ?? "alert")),
|
|
370
|
+
el("span", {
|
|
371
|
+
text: ` observed ${String(alert.observed)} vs ${String(alert.threshold)} (from ${String(alert.baselineSessions)} baseline session(s))`,
|
|
372
|
+
}),
|
|
373
|
+
alert.detail
|
|
374
|
+
? el("div", { class: "muted reason", text: String(alert.detail) })
|
|
375
|
+
: null,
|
|
376
|
+
]),
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
el("p", { class: "muted", text: String(body.baselineNote ?? "") }),
|
|
380
|
+
],
|
|
381
|
+
),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return nodes;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function fmtTarget(value, unit) {
|
|
388
|
+
if (value === null || value === undefined) return "—";
|
|
389
|
+
if (unit === "rate") return fmtPct(Number(value));
|
|
390
|
+
if (unit === "ms") return `${Math.round(Number(value))} ms`;
|
|
391
|
+
return `${Number(value).toFixed(2)} ${String(unit)}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Justification
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
function justificationCard(body, ctx) {
|
|
399
|
+
const records = Array.isArray(body.records) ? body.records : [];
|
|
400
|
+
const byTool = Array.isArray(body.byTool) ? body.byTool : [];
|
|
401
|
+
const state = el("div", { class: "control-state" });
|
|
402
|
+
|
|
403
|
+
const tool = el("input", {
|
|
404
|
+
class: "input mono",
|
|
405
|
+
type: "text",
|
|
406
|
+
placeholder: "tool name",
|
|
407
|
+
"aria-label": "tool name",
|
|
408
|
+
});
|
|
409
|
+
const preflight = el("button", {
|
|
410
|
+
class: "btn",
|
|
411
|
+
type: "button",
|
|
412
|
+
text: "Preflight (never runs the tool)",
|
|
413
|
+
});
|
|
414
|
+
preflight.addEventListener("click", async () => {
|
|
415
|
+
if (tool.value === "") {
|
|
416
|
+
toast("name a tool first", "info");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
preflight.disabled = true;
|
|
420
|
+
const answer = await m3(api.justificationPreflight({ id: ctx.id }, { tool: tool.value }));
|
|
421
|
+
preflight.disabled = false;
|
|
422
|
+
if (!answer.ok) {
|
|
423
|
+
state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const result = answer.body;
|
|
427
|
+
const history = result.history && typeof result.history === "object" ? result.history : {};
|
|
428
|
+
state.replaceChildren(
|
|
429
|
+
dot(
|
|
430
|
+
result.likelyVerdict === "deny" ? "bad" : result.likelyVerdict === "allow" ? "ok" : "off",
|
|
431
|
+
`likely ${String(result.likelyVerdict)}`,
|
|
432
|
+
),
|
|
433
|
+
el("span", {
|
|
434
|
+
class: "chip",
|
|
435
|
+
text: `${String(history.evaluations ?? 0)} past evaluation(s), ${String(history.denials ?? 0)} denied`,
|
|
436
|
+
}),
|
|
437
|
+
el("span", { class: "chip", text: "the tool was NOT executed" }),
|
|
438
|
+
el("div", { class: "muted reason", text: String(result.note ?? "") }),
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const calibrate = el("button", { class: "btn btn-ghost", type: "button", text: "Calibrate" });
|
|
443
|
+
calibrate.addEventListener("click", async () => {
|
|
444
|
+
calibrate.disabled = true;
|
|
445
|
+
const answer = await m3(api.justificationCalibrate({ id: ctx.id }, { apply: true }));
|
|
446
|
+
calibrate.disabled = false;
|
|
447
|
+
if (!answer.ok) {
|
|
448
|
+
toast(String(answer.body.error ?? answer.status));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
state.replaceChildren(
|
|
452
|
+
dot("ok", "calibration queued"),
|
|
453
|
+
el("div", { class: "muted reason", text: String(answer.body.applyNote ?? "") }),
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const nodes = [
|
|
458
|
+
el("div", { class: "rollup" }, [
|
|
459
|
+
dot(records.length > 0 ? "ok" : "off", `${records.length} evaluated call(s)`),
|
|
460
|
+
body.config
|
|
461
|
+
? el("span", { class: "chip", text: `judge: ${String(body.config.judge ?? "declared")}` })
|
|
462
|
+
: el("span", { class: "chip", text: "no security.justification block" }),
|
|
463
|
+
]),
|
|
464
|
+
el("div", { class: "control-action" }, [
|
|
465
|
+
el("div", { class: "control-row" }, [tool, preflight, calibrate]),
|
|
466
|
+
state,
|
|
467
|
+
]),
|
|
468
|
+
el("p", { class: "muted", text: String(body.linkNote ?? "") }),
|
|
469
|
+
];
|
|
470
|
+
if (byTool.length > 0) {
|
|
471
|
+
nodes.push(
|
|
472
|
+
el(
|
|
473
|
+
"div",
|
|
474
|
+
{ class: "chip-group" },
|
|
475
|
+
byTool.map((entry) =>
|
|
476
|
+
el("span", {
|
|
477
|
+
class: "chip",
|
|
478
|
+
text: `${String(entry.toolName)}: ${String(entry.allow)}✓ / ${String(entry.deny)}✗`,
|
|
479
|
+
}),
|
|
480
|
+
),
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (records.length === 0) {
|
|
485
|
+
nodes.push(m3Empty(body, "No justification records yet"));
|
|
486
|
+
return nodes;
|
|
487
|
+
}
|
|
488
|
+
nodes.push(
|
|
489
|
+
collapsible(
|
|
490
|
+
[el("span", { class: "muted", text: "verbatim justifications" })],
|
|
491
|
+
[
|
|
492
|
+
el(
|
|
493
|
+
"ul",
|
|
494
|
+
{ class: "check-list" },
|
|
495
|
+
records
|
|
496
|
+
.slice(-30)
|
|
497
|
+
.reverse()
|
|
498
|
+
.map((record) =>
|
|
499
|
+
el("li", { class: "check" }, [
|
|
500
|
+
record.verdict === "deny" ? dot("bad", "deny") : dot("ok", "allow"),
|
|
501
|
+
el("code", { text: String(record.toolName) }),
|
|
502
|
+
el("div", { class: "muted", text: String(record.justification) }),
|
|
503
|
+
record.reason
|
|
504
|
+
? el("div", { class: "muted reason", text: String(record.reason) })
|
|
505
|
+
: null,
|
|
506
|
+
]),
|
|
507
|
+
),
|
|
508
|
+
),
|
|
509
|
+
],
|
|
510
|
+
),
|
|
511
|
+
);
|
|
512
|
+
return nodes;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// PII
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
function piiCard(body, ctx, reload) {
|
|
520
|
+
const nodes = [
|
|
521
|
+
el("div", { class: "rollup" }, [
|
|
522
|
+
dot(
|
|
523
|
+
body.present === true ? "ok" : "off",
|
|
524
|
+
`${Number(body.allowEntries) || 0} allow entr(ies)`,
|
|
525
|
+
),
|
|
526
|
+
]),
|
|
527
|
+
el("p", { class: "muted", text: String(body.valuesNote ?? "") }),
|
|
528
|
+
el("p", { class: "muted", text: String(body.hitCountsNote ?? "") }),
|
|
529
|
+
];
|
|
530
|
+
if (body.present !== true) {
|
|
531
|
+
nodes.push(m3Empty(body, "No pii-policy.json — the built-in detector set applies"));
|
|
532
|
+
} else {
|
|
533
|
+
nodes.push(
|
|
534
|
+
collapsible([el("span", { class: "muted", text: "policy" })], [jsonPre(body.policy)]),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
nodes.push(
|
|
538
|
+
policyTuner("PII policy", body.policy, ctx, reload, (policy, dryRun, confirmName) =>
|
|
539
|
+
api.piiTune({ id: ctx.id }, { policy, dryRun, ...(confirmName ? { confirmName } : {}) }),
|
|
540
|
+
),
|
|
541
|
+
);
|
|
542
|
+
return nodes;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The shared policy tuner: paste a policy, see the BEFORE/AFTER, and only
|
|
547
|
+
* then confirm. `dryRun` defaults to true on the server too — an omitted
|
|
548
|
+
* flag must never mean "do it".
|
|
549
|
+
*/
|
|
550
|
+
function policyTuner(label, current, ctx, reload, call) {
|
|
551
|
+
const area = el("textarea", {
|
|
552
|
+
class: "input",
|
|
553
|
+
rows: "6",
|
|
554
|
+
spellcheck: "false",
|
|
555
|
+
"aria-label": `${label} JSON`,
|
|
556
|
+
});
|
|
557
|
+
area.value = JSON.stringify(current ?? {}, null, 2);
|
|
558
|
+
const confirm = el("input", {
|
|
559
|
+
class: "input mono",
|
|
560
|
+
type: "text",
|
|
561
|
+
placeholder: "type the harness name to apply",
|
|
562
|
+
"aria-label": "typed confirmation",
|
|
563
|
+
});
|
|
564
|
+
const state = el("div", { class: "control-state" });
|
|
565
|
+
const previewBtn = el("button", { class: "btn", type: "button", text: "Preview" });
|
|
566
|
+
const applyBtn = el("button", { class: "btn btn-danger", type: "button", text: "Apply" });
|
|
567
|
+
|
|
568
|
+
const parse = () => {
|
|
569
|
+
try {
|
|
570
|
+
return JSON.parse(area.value);
|
|
571
|
+
} catch {
|
|
572
|
+
toast("that is not valid JSON");
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
previewBtn.addEventListener("click", async () => {
|
|
578
|
+
const policy = parse();
|
|
579
|
+
if (policy === undefined) return;
|
|
580
|
+
previewBtn.disabled = true;
|
|
581
|
+
const answer = await m3(call(policy, true, ""));
|
|
582
|
+
previewBtn.disabled = false;
|
|
583
|
+
if (!answer.ok) {
|
|
584
|
+
state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const preview =
|
|
588
|
+
answer.body.preview && typeof answer.body.preview === "object" ? answer.body.preview : {};
|
|
589
|
+
state.replaceChildren(
|
|
590
|
+
preview.changed ? dot("warn", "this would change the policy") : dot("off", "no change"),
|
|
591
|
+
collapsible([el("span", { class: "muted", text: "before" })], [jsonPre(preview.before)]),
|
|
592
|
+
collapsible([el("span", { class: "muted", text: "after" })], [jsonPre(preview.after)]),
|
|
593
|
+
el("div", { class: "muted reason", text: String(answer.body.note ?? "") }),
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
applyBtn.addEventListener("click", async () => {
|
|
598
|
+
const policy = parse();
|
|
599
|
+
if (policy === undefined) return;
|
|
600
|
+
applyBtn.disabled = true;
|
|
601
|
+
const answer = await m3(call(policy, false, confirm.value));
|
|
602
|
+
applyBtn.disabled = false;
|
|
603
|
+
if (!answer.ok) {
|
|
604
|
+
state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
state.replaceChildren(
|
|
608
|
+
answer.body.wrote === true
|
|
609
|
+
? dot("ok", "written")
|
|
610
|
+
: dot("warn", String(answer.body.note ?? "handed off")),
|
|
611
|
+
answer.body.specEdit
|
|
612
|
+
? collapsible(
|
|
613
|
+
[el("span", { class: "muted", text: "the edit, for the spec write path" })],
|
|
614
|
+
[jsonPre(answer.body.specEdit)],
|
|
615
|
+
)
|
|
616
|
+
: null,
|
|
617
|
+
);
|
|
618
|
+
if (answer.body.wrote === true) reload();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
return el("div", { class: "control-action" }, [
|
|
622
|
+
el("p", { class: "muted", text: `${label} — dry run first, then a typed confirmation` }),
|
|
623
|
+
area,
|
|
624
|
+
el("div", { class: "row-editor" }, [confirm, previewBtn, applyBtn]),
|
|
625
|
+
state,
|
|
626
|
+
]);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// Corpus + sandbox
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
function corpusCard(body, ctx, reload) {
|
|
634
|
+
const run = el("button", { class: "btn", type: "button", text: "Run corpus check" });
|
|
635
|
+
run.addEventListener("click", async () => {
|
|
636
|
+
run.disabled = true;
|
|
637
|
+
const answer = await m3(api.securityCorpusCheck({ id: ctx.id }, {}));
|
|
638
|
+
run.disabled = false;
|
|
639
|
+
if (!answer.ok) {
|
|
640
|
+
toast(String(answer.body.error ?? answer.status));
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
toast("corpus check queued", "info");
|
|
644
|
+
reload();
|
|
645
|
+
});
|
|
646
|
+
const last = body.lastCheck && typeof body.lastCheck === "object" ? body.lastCheck : null;
|
|
647
|
+
const nodes = [
|
|
648
|
+
el("div", { class: "rollup" }, [
|
|
649
|
+
dot(body.present === true ? "ok" : "off", `${Number(body.cases) || 0} case(s)`),
|
|
650
|
+
el("span", { class: "chip", text: `${Number(body.candidateRules) || 0} candidate rule(s)` }),
|
|
651
|
+
typeof body.builtAt === "string"
|
|
652
|
+
? el("span", {
|
|
653
|
+
class: "asof",
|
|
654
|
+
title: body.builtAt,
|
|
655
|
+
text: `built ${fmtRelativeTime(body.builtAt, Date.now())}`,
|
|
656
|
+
})
|
|
657
|
+
: null,
|
|
658
|
+
]),
|
|
659
|
+
el("p", { class: "muted", text: String(body.payloadNote ?? "") }),
|
|
660
|
+
el("div", { class: "control-action" }, [
|
|
661
|
+
el("div", { class: "control-row" }, [run]),
|
|
662
|
+
el("div", { class: "control-state" }, [
|
|
663
|
+
last === null
|
|
664
|
+
? el("span", { class: "muted", text: "no check has run from this manager yet" })
|
|
665
|
+
: dot(last.exitCode === 0 ? "ok" : "warn", `last check ${String(last.state)}`),
|
|
666
|
+
]),
|
|
667
|
+
]),
|
|
668
|
+
];
|
|
669
|
+
if (body.present !== true) nodes.push(m3Empty(body, "No security corpus yet"));
|
|
670
|
+
return nodes;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function sandboxCard(body) {
|
|
674
|
+
const backends = Array.isArray(body.backends) ? body.backends : [];
|
|
675
|
+
const nodes = [
|
|
676
|
+
el("div", { class: "rollup" }, [
|
|
677
|
+
body.declared === true ? dot("ok", "sandbox declared") : dot("off", "no sandbox block"),
|
|
678
|
+
...backends.map((backend) =>
|
|
679
|
+
backend.available
|
|
680
|
+
? dot("ok", `${String(backend.name)} available`)
|
|
681
|
+
: dot("off", `${String(backend.name)} not on PATH`),
|
|
682
|
+
),
|
|
683
|
+
]),
|
|
684
|
+
el("p", { text: String(body.wouldHappen ?? "") }),
|
|
685
|
+
el("p", { class: "muted", text: String(body.probeNote ?? "") }),
|
|
686
|
+
];
|
|
687
|
+
if (body.config) {
|
|
688
|
+
nodes.push(
|
|
689
|
+
collapsible([el("span", { class: "muted", text: "sandbox config" })], [jsonPre(body.config)]),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (body.declared !== true) nodes.push(m3Empty(body, "This spec declares no sandbox"));
|
|
693
|
+
return nodes;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
// Onchain
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
function onchainCard(body, sentinel, ctx) {
|
|
701
|
+
if (body.shapeGated !== true && body.transactionPolicy === null) {
|
|
702
|
+
return [m3Empty(body, `Not an onchain shape (target: ${String(body.target)})`)];
|
|
703
|
+
}
|
|
704
|
+
const nodes = [
|
|
705
|
+
el("div", { class: "rollup" }, [
|
|
706
|
+
// transaction_policy is the ceiling on what value the agent can move,
|
|
707
|
+
// so it leads this panel rather than sitting in a fold.
|
|
708
|
+
body.transactionPolicy
|
|
709
|
+
? dot("ok", `approval: ${String(body.approvalMode ?? "declared")}`)
|
|
710
|
+
: dot("bad", "no transaction_policy — nothing caps what this agent can move"),
|
|
711
|
+
el("span", { class: "chip", text: `${(body.receipts || []).length} broadcast(s)` }),
|
|
712
|
+
]),
|
|
713
|
+
el("p", { class: "muted", text: String(body.keyNote ?? "") }),
|
|
714
|
+
body.transactionPolicy
|
|
715
|
+
? collapsible(
|
|
716
|
+
[el("span", { class: "muted", text: "transaction_policy" })],
|
|
717
|
+
[jsonPre(body.transactionPolicy)],
|
|
718
|
+
)
|
|
719
|
+
: null,
|
|
720
|
+
];
|
|
721
|
+
for (const [label, value] of [
|
|
722
|
+
["chains", body.chains],
|
|
723
|
+
["wallets", body.wallets],
|
|
724
|
+
["contracts", body.contracts],
|
|
725
|
+
["triggers", body.triggers],
|
|
726
|
+
]) {
|
|
727
|
+
if (value === null || value === undefined) continue;
|
|
728
|
+
nodes.push(collapsible([el("span", { class: "muted", text: label })], [jsonPre(value)]));
|
|
729
|
+
}
|
|
730
|
+
if (sentinel) {
|
|
731
|
+
const baselines = Array.isArray(sentinel.baselines) ? sentinel.baselines : [];
|
|
732
|
+
nodes.push(
|
|
733
|
+
el("div", { class: "rollup" }, [
|
|
734
|
+
dot(sentinel.present === true ? "ok" : "off", `${baselines.length} contract baseline(s)`),
|
|
735
|
+
el("span", { class: "muted", text: String(sentinel.note ?? "") }),
|
|
736
|
+
]),
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
nodes.push(
|
|
740
|
+
policyTuner(
|
|
741
|
+
"transaction_policy",
|
|
742
|
+
body.transactionPolicy,
|
|
743
|
+
ctx,
|
|
744
|
+
() => {},
|
|
745
|
+
(policy, dryRun, confirmName) =>
|
|
746
|
+
api.onchainTune(
|
|
747
|
+
{ id: ctx.id },
|
|
748
|
+
{ policy, dryRun, ...(confirmName ? { confirmName } : {}) },
|
|
749
|
+
),
|
|
750
|
+
),
|
|
751
|
+
);
|
|
752
|
+
return nodes;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
// Compliance + retention
|
|
757
|
+
// ---------------------------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
function complianceCard(body, ctx, reload) {
|
|
760
|
+
const bundles = Array.isArray(body.bundles) ? body.bundles : [];
|
|
761
|
+
const frameworks = Array.isArray(body.frameworks) ? body.frameworks : [];
|
|
762
|
+
const select = el(
|
|
763
|
+
"select",
|
|
764
|
+
{ class: "input", "aria-label": "framework" },
|
|
765
|
+
frameworks.map((framework) =>
|
|
766
|
+
el("option", { value: String(framework), text: String(framework) }),
|
|
767
|
+
),
|
|
768
|
+
);
|
|
769
|
+
const generate = el("button", { class: "btn", type: "button", text: "Generate evidence" });
|
|
770
|
+
generate.addEventListener("click", async () => {
|
|
771
|
+
generate.disabled = true;
|
|
772
|
+
const answer = await m3(api.complianceEvidence({ id: ctx.id }, { framework: select.value }));
|
|
773
|
+
generate.disabled = false;
|
|
774
|
+
if (!answer.ok) {
|
|
775
|
+
toast(String(answer.body.error ?? answer.status));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
toast("evidence collection queued", "info");
|
|
779
|
+
reload();
|
|
780
|
+
});
|
|
781
|
+
const nodes = [
|
|
782
|
+
el("div", { class: "rollup" }, [
|
|
783
|
+
dot(bundles.length > 0 ? "ok" : "off", `${bundles.length} bundle(s)`),
|
|
784
|
+
]),
|
|
785
|
+
el("div", { class: "row-editor" }, [select, generate]),
|
|
786
|
+
el("p", { class: "muted", text: String(body.retireNote ?? "") }),
|
|
787
|
+
];
|
|
788
|
+
if (bundles.length === 0) {
|
|
789
|
+
nodes.push(m3Empty(body, "No evidence bundles generated yet"));
|
|
790
|
+
return nodes;
|
|
791
|
+
}
|
|
792
|
+
const tbody = el("tbody");
|
|
793
|
+
for (const bundle of bundles) {
|
|
794
|
+
tbody.appendChild(
|
|
795
|
+
el("tr", null, [
|
|
796
|
+
el("td", { class: "mono", text: String(bundle.name) }),
|
|
797
|
+
el("td", { text: String(bundle.framework ?? "—") }),
|
|
798
|
+
el("td", { text: String(bundle.period ?? "—") }),
|
|
799
|
+
el("td", {
|
|
800
|
+
class: "muted",
|
|
801
|
+
text: bundle.generatedAt ? fmtRelativeTime(bundle.generatedAt, Date.now()) : "—",
|
|
802
|
+
}),
|
|
803
|
+
]),
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
nodes.push(
|
|
807
|
+
el("div", { class: "table-scroll" }, [
|
|
808
|
+
el("table", { class: "fleet" }, [
|
|
809
|
+
el(
|
|
810
|
+
"thead",
|
|
811
|
+
null,
|
|
812
|
+
el(
|
|
813
|
+
"tr",
|
|
814
|
+
null,
|
|
815
|
+
["Bundle", "Framework", "Period", "Generated"].map((h) => el("th", { text: h })),
|
|
816
|
+
),
|
|
817
|
+
),
|
|
818
|
+
tbody,
|
|
819
|
+
]),
|
|
820
|
+
]),
|
|
821
|
+
);
|
|
822
|
+
return nodes;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function retentionCard(body, ctx) {
|
|
826
|
+
const pins = Array.isArray(body.pins) ? body.pins : [];
|
|
827
|
+
const windows = Array.isArray(body.auditWindows) ? body.auditWindows : [];
|
|
828
|
+
const nodes = [
|
|
829
|
+
el("div", { class: "rollup" }, [
|
|
830
|
+
body.malformed === true
|
|
831
|
+
? dot("bad", "retention.json will not parse — enforcement is refused, not guessed")
|
|
832
|
+
: dot(body.fromFile === true ? "ok" : "off", `${pins.length} pinned session(s)`),
|
|
833
|
+
body.sessionMaxAgeDays === null
|
|
834
|
+
? null
|
|
835
|
+
: el("span", { class: "chip", text: `TTL ${String(body.sessionMaxAgeDays)}d` }),
|
|
836
|
+
...windows.map((window) =>
|
|
837
|
+
window.active
|
|
838
|
+
? dot("warn", `${String(window.frameworkId)}/${String(window.controlId)} defers deletion`)
|
|
839
|
+
: dot("off", `${String(window.frameworkId)}/${String(window.controlId)} expired`),
|
|
840
|
+
),
|
|
841
|
+
]),
|
|
842
|
+
el("p", { class: "muted", text: String(body.auditChainNote ?? "") }),
|
|
843
|
+
];
|
|
844
|
+
if (body.fromFile !== true) nodes.push(m3Empty(body, "No retention.json"));
|
|
845
|
+
if (pins.length > 0) {
|
|
846
|
+
nodes.push(
|
|
847
|
+
el(
|
|
848
|
+
"div",
|
|
849
|
+
{ class: "chip-group" },
|
|
850
|
+
pins.map((pin) => el("span", { class: "chip mono", text: String(pin) })),
|
|
851
|
+
),
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
nodes.push(retentionRun("sweep", ctx), retentionRun("purge", ctx));
|
|
855
|
+
return nodes;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Dry-run FIRST, then a typed confirm. Two gestures, never one: the plan is
|
|
860
|
+
* the thing the operator is consenting to, and they cannot consent to a plan
|
|
861
|
+
* they have not seen.
|
|
862
|
+
*/
|
|
863
|
+
function retentionRun(action, ctx) {
|
|
864
|
+
const state = el("div", { class: "control-state" });
|
|
865
|
+
const confirm = el("input", {
|
|
866
|
+
class: "input mono",
|
|
867
|
+
type: "text",
|
|
868
|
+
placeholder: "type the harness name",
|
|
869
|
+
"aria-label": `typed confirmation for ${action}`,
|
|
870
|
+
});
|
|
871
|
+
const real = el("button", {
|
|
872
|
+
class: "btn btn-danger",
|
|
873
|
+
type: "button",
|
|
874
|
+
text: `Run ${action} for real`,
|
|
875
|
+
disabled: true,
|
|
876
|
+
});
|
|
877
|
+
const plan = el("button", { class: "btn", type: "button", text: `Plan ${action} (--dry-run)` });
|
|
878
|
+
|
|
879
|
+
const call = (dryRun) =>
|
|
880
|
+
action === "sweep"
|
|
881
|
+
? api.retentionSweep(
|
|
882
|
+
{ id: ctx.id },
|
|
883
|
+
{ dryRun, ...(dryRun ? {} : { confirmName: confirm.value }) },
|
|
884
|
+
)
|
|
885
|
+
: api.retentionPurge(
|
|
886
|
+
{ id: ctx.id },
|
|
887
|
+
{ dryRun, ...(dryRun ? {} : { confirmName: confirm.value }) },
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
plan.addEventListener("click", async () => {
|
|
891
|
+
plan.disabled = true;
|
|
892
|
+
const answer = await m3(call(true));
|
|
893
|
+
plan.disabled = false;
|
|
894
|
+
if (!answer.ok) {
|
|
895
|
+
state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
// The real run unlocks only once the plan has been asked for.
|
|
899
|
+
real.disabled = false;
|
|
900
|
+
state.replaceChildren(
|
|
901
|
+
dot("off", `plan queued as job ${String(answer.body.job?.jobId ?? "?")}`),
|
|
902
|
+
el("div", { class: "muted reason", text: String(answer.body.note ?? "") }),
|
|
903
|
+
el("div", { class: "muted reason", text: String(answer.body.pinsNote ?? "") }),
|
|
904
|
+
);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
real.addEventListener("click", async () => {
|
|
908
|
+
real.disabled = true;
|
|
909
|
+
const answer = await m3(call(false));
|
|
910
|
+
real.disabled = false;
|
|
911
|
+
if (!answer.ok) {
|
|
912
|
+
state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
state.replaceChildren(
|
|
916
|
+
dot("warn", `${action} queued as job ${String(answer.body.job?.jobId ?? "?")}`),
|
|
917
|
+
el("div", { class: "muted reason", text: String(answer.body.note ?? "") }),
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
return el("div", { class: "control-action" }, [
|
|
922
|
+
el("div", { class: "control-row" }, [plan, confirm, real]),
|
|
923
|
+
state,
|
|
924
|
+
]);
|
|
925
|
+
}
|