@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.
Files changed (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Datasets — the dataset registry, its hygiene checks, and the growth verbs.
3
+ *
4
+ * Rules the screen carries rather than hides: a verify MISMATCH means
5
+ * TAMPERED (not stale); `<spec>-ratings` and `<spec>-regressions` are
6
+ * auto-maintained and invite no hand edit the next distill would overwrite;
7
+ * the test split is locked to the release flow with a visible burn count;
8
+ * and quarantined samples appear beside the registry with their provenance,
9
+ * because a quarantined sample nobody can see is a bug nobody can file.
10
+ *
11
+ * `dataset audit` (registry integrity) and `dataset lint` (canary
12
+ * contamination across specs and few-shot pools) answer different questions
13
+ * and get different buttons.
14
+ *
15
+ * Every node is built with el() — markup strings are banned app-wide, so a
16
+ * mined sample's text can never inject into this page.
17
+ */
18
+
19
+ import { api } from "../api.js";
20
+ import { clear, collapsible, dot, el, emptyState, jsonPre, skeleton, toast } from "../dom.js";
21
+ import { fmtCount, fmtRelativeTime } from "../util.js";
22
+
23
+ export async function renderData(root, ctx) {
24
+ clear(root).appendChild(skeleton(4));
25
+ const [registry, status, quarantine] = await Promise.all(
26
+ [api.datasets, api.datasetStatus, api.datasetQuarantine].map((fn) =>
27
+ fn({ id: ctx.id }).catch((err) => ({ ok: false, status: 0, body: { error: String(err) } })),
28
+ ),
29
+ );
30
+ clear(root);
31
+ const reload = () => renderData(root, ctx);
32
+ root.appendChild(registryCard(ctx, registry, reload));
33
+ root.appendChild(statusCard(status));
34
+ root.appendChild(quarantineCard(quarantine));
35
+ root.appendChild(hygieneCard(ctx));
36
+ root.appendChild(growthCard(ctx, reload));
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // shared bits
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function payload(res) {
44
+ return res && typeof res === "object" && res.body && typeof res.body === "object" ? res.body : {};
45
+ }
46
+
47
+ function panel(title, res, draw) {
48
+ const data = payload(res);
49
+ const card = el("div", { class: "card" }, [
50
+ el("h3", { class: "card-title" }, [el("span", { text: title })]),
51
+ ]);
52
+ if (res && res.ok === false && data.present === undefined) {
53
+ card.appendChild(el("p", { class: "muted", text: `unavailable (HTTP ${String(res.status)})` }));
54
+ return card;
55
+ }
56
+ if (data.present === false) {
57
+ card.appendChild(emptyState(String(data.note ?? "Nothing yet"), data.verb ?? null));
58
+ return card;
59
+ }
60
+ if (typeof data.note === "string" && data.note !== "") {
61
+ card.appendChild(el("p", { class: "muted", text: data.note }));
62
+ }
63
+ draw(card, data);
64
+ return card;
65
+ }
66
+
67
+ function table(headers, rows) {
68
+ return el("div", { class: "table-scroll" }, [
69
+ el("table", { class: "fleet" }, [
70
+ el(
71
+ "thead",
72
+ null,
73
+ el(
74
+ "tr",
75
+ null,
76
+ headers.map((h) => el("th", { text: h })),
77
+ ),
78
+ ),
79
+ el("tbody", null, rows),
80
+ ]),
81
+ ]);
82
+ }
83
+
84
+ function field(label, node) {
85
+ return el("label", { class: "field" }, [el("span", { class: "kv-k", text: label }), node]);
86
+ }
87
+
88
+ function input(name, attrs) {
89
+ return el("input", { class: "input", name, ...(attrs ?? {}) });
90
+ }
91
+
92
+ function readText(form, name) {
93
+ const raw = form.elements[name]?.value?.trim();
94
+ return raw ? raw : undefined;
95
+ }
96
+
97
+ async function call(fn, params, body) {
98
+ return await fn(params, body).catch((err) => ({
99
+ ok: false,
100
+ status: 0,
101
+ body: { error: String(err) },
102
+ }));
103
+ }
104
+
105
+ /** Split sizes as one string, with the locked holdout marked. */
106
+ function splitsText(splits) {
107
+ if (!splits) return "—";
108
+ const test =
109
+ splits.test === null || splits.test === undefined ? "no test" : `${splits.test} test 🔒`;
110
+ return `${fmtCount(splits.train)} train · ${fmtCount(splits.dev)} dev · ${test}`;
111
+ }
112
+
113
+ /** Provenance is a TAXONOMY — render the tally, not a free-text note. */
114
+ function provenanceChips(tally) {
115
+ const entries = Object.entries(tally ?? {});
116
+ if (entries.length === 0) return el("span", { class: "muted", text: "—" });
117
+ return el(
118
+ "span",
119
+ { class: "cell-caps" },
120
+ entries.map(([source, count]) =>
121
+ el("span", {
122
+ // A canary sample among ordinary rows is what `dataset lint` hunts.
123
+ class: source === "canary" || source === "(unlabelled)" ? "chip chip-warn" : "chip",
124
+ text: `${source} ${String(count)}`,
125
+ }),
126
+ ),
127
+ );
128
+ }
129
+
130
+ /** A verify result is binary and its language matters: a mismatch means the
131
+ * stored hashes no longer describe the content — TAMPERED, never "stale". */
132
+ function verifyDot(verify) {
133
+ if (!verify) return dot("unknown", "not verified");
134
+ return verify.ok ? dot("ok", "intact") : dot("bad", `tampered (${fmtCount(verify.mismatches)})`);
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // the registry
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function registryCard(ctx, res, reload) {
142
+ return panel("Dataset registry", res, (card, data) => {
143
+ const rows = (data.datasets ?? []).map((row) =>
144
+ el("tr", null, [
145
+ el("td", null, [
146
+ el("span", { class: "mono", text: String(row.name) }),
147
+ row.autoMaintained
148
+ ? el("span", {
149
+ class: "chip chip-group",
150
+ title: String(row.autoMaintained),
151
+ text: "auto-maintained",
152
+ })
153
+ : null,
154
+ ]),
155
+ el("td", { class: "mono", text: String(row.latestVersion ?? "—") }),
156
+ el("td", { text: splitsText(row.splits) }),
157
+ el("td", null, [provenanceChips(row.provenance)]),
158
+ el("td", null, [verifyDot(row.verify)]),
159
+ el("td", { class: "num", text: fmtCount(row.testSplitBurn) }),
160
+ el("td", { class: "cell-actions" }, [
161
+ detailBtn(ctx, row.name),
162
+ verifyBtn(ctx, row.name, reload),
163
+ ]),
164
+ ]),
165
+ );
166
+ card.appendChild(
167
+ table(["Dataset", "Latest", "Splits", "Provenance", "Verify", "Holdout burn", ""], rows),
168
+ );
169
+ card.appendChild(
170
+ el("p", {
171
+ class: "muted",
172
+ text: "The held-out test split is locked to the release flow; every spend is counted as a burn.",
173
+ }),
174
+ );
175
+ });
176
+ }
177
+
178
+ function detailBtn(ctx, name) {
179
+ const holder = el("div");
180
+ const btn = el("button", { class: "btn btn-ghost", type: "button", text: "Versions" });
181
+ btn.addEventListener("click", async () => {
182
+ btn.disabled = true;
183
+ const res = await call(api.dataset, { id: ctx.id, name: String(name) });
184
+ btn.disabled = false;
185
+ clear(holder);
186
+ if (!res.ok) {
187
+ toast(String(payload(res).error ?? `HTTP ${String(res.status)}`), "error");
188
+ return;
189
+ }
190
+ const data = payload(res);
191
+ if (data.present === false) {
192
+ holder.appendChild(emptyState(String(data.note), data.verb ?? null));
193
+ return;
194
+ }
195
+ holder.appendChild(
196
+ table(
197
+ ["Version", "Created", "Splits", "Verify", "Burn"],
198
+ (data.versions ?? []).map((v) =>
199
+ el("tr", null, [
200
+ el("td", { class: "mono", text: String(v.version) }),
201
+ el("td", { text: fmtRelativeTime(v.createdAt, Date.now()) }),
202
+ el("td", { text: splitsText(v.splits) }),
203
+ el("td", null, [verifyDot(v.verify)]),
204
+ el("td", { class: "num", text: fmtCount(v.burn?.count) }),
205
+ ]),
206
+ ),
207
+ ),
208
+ );
209
+ if (data.testSplit) {
210
+ holder.appendChild(el("p", { class: "gated-why", text: String(data.testSplit.note) }));
211
+ }
212
+ });
213
+ return el("div", { class: "job-action" }, [btn, holder]);
214
+ }
215
+
216
+ function verifyBtn(ctx, name, reload) {
217
+ const btn = el("button", { class: "btn btn-ghost", type: "button", text: "Verify" });
218
+ btn.addEventListener("click", async () => {
219
+ btn.disabled = true;
220
+ const res = await call(api.datasetVerify, { id: ctx.id }, { name: String(name) });
221
+ btn.disabled = false;
222
+ if (!res.ok) {
223
+ toast(String(payload(res).error ?? `HTTP ${String(res.status)}`), "error");
224
+ return;
225
+ }
226
+ const tampered = payload(res).tampered ?? [];
227
+ toast(
228
+ tampered.length === 0
229
+ ? `${String(name)}: every version matches its stored hashes`
230
+ : `${String(name)}: TAMPERED — ${tampered.join(", ")}`,
231
+ tampered.length === 0 ? "info" : "error",
232
+ );
233
+ if (reload) reload();
234
+ });
235
+ return btn;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // status + quarantine
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function statusCard(res) {
243
+ return panel("Freshness, saturation and burn", res, (card, data) => {
244
+ card.appendChild(
245
+ table(
246
+ ["Dataset", "Version", "Age", "Runs", "Last run", "Always-passing", "Burn"],
247
+ (data.datasets ?? []).map((row) => {
248
+ const saturated = row.saturation?.alwaysPassing ?? [];
249
+ return el("tr", null, [
250
+ el("td", { class: "mono", text: String(row.name) }),
251
+ el("td", { class: "mono", text: String(row.version) }),
252
+ el("td", {
253
+ class: "num",
254
+ text: row.ageDays === null ? "—" : `${String(row.ageDays)}d`,
255
+ }),
256
+ el("td", { class: "num", text: fmtCount(row.runCount) }),
257
+ el("td", { text: fmtRelativeTime(row.lastRunTs, Date.now()) }),
258
+ el("td", null, [
259
+ saturated.length === 0
260
+ ? dot("ok", "discriminating")
261
+ : dot("warn", `${String(saturated.length)} saturated`),
262
+ ]),
263
+ el("td", { class: "num", text: fmtCount(row.testSplitBurn) }),
264
+ ]);
265
+ }),
266
+ ),
267
+ );
268
+ });
269
+ }
270
+
271
+ function quarantineCard(res) {
272
+ return panel("Quarantine", res, (card, data) => {
273
+ card.appendChild(
274
+ table(
275
+ ["Sample", "Source", "Why it was pulled", "File"],
276
+ (data.entries ?? []).map((entry) =>
277
+ el("tr", null, [
278
+ el("td", { class: "mono", text: String(entry.id ?? "—") }),
279
+ el("td", null, [
280
+ el("span", { class: "chip", text: String(entry.source ?? "(unlabelled)") }),
281
+ ]),
282
+ el("td", { class: "muted", text: String(entry.reason ?? entry.input ?? "") }),
283
+ el("td", { class: "mono muted", text: String(entry.file) }),
284
+ ]),
285
+ ),
286
+ ),
287
+ );
288
+ if (data.promoteVerb) {
289
+ card.appendChild(
290
+ el("p", { class: "muted" }, [
291
+ el("span", { text: "Promote accepted candidates with " }),
292
+ el("code", { text: String(data.promoteVerb) }),
293
+ ]),
294
+ );
295
+ }
296
+ });
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // hygiene — two DIFFERENT questions, two buttons
301
+ // ---------------------------------------------------------------------------
302
+
303
+ function hygieneCard(ctx) {
304
+ const out = el("div");
305
+ const card = el("div", { class: "card" }, [
306
+ el("h3", { class: "card-title" }, [el("span", { text: "Hygiene" })]),
307
+ el("p", {
308
+ class: "muted",
309
+ text: "Audit asks whether the registry is internally consistent. Lint asks whether canary samples have leaked into the spec or the few-shot pool. They are not the same check.",
310
+ }),
311
+ ]);
312
+
313
+ const audit = el("button", { class: "btn btn-ghost", type: "button", text: "Audit registry" });
314
+ audit.addEventListener("click", async () => {
315
+ audit.disabled = true;
316
+ const res = await call(api.datasetAudit, { id: ctx.id }, {});
317
+ audit.disabled = false;
318
+ clear(out);
319
+ if (!res.ok) {
320
+ toast(String(payload(res).error ?? `HTTP ${String(res.status)}`), "error");
321
+ return;
322
+ }
323
+ const data = payload(res);
324
+ const findings = data.findings ?? [];
325
+ if (findings.length === 0) {
326
+ out.appendChild(emptyState("No integrity findings", null));
327
+ return;
328
+ }
329
+ out.appendChild(
330
+ table(
331
+ ["Level", "Dataset", "Version", "Finding"],
332
+ findings.map((f) =>
333
+ el("tr", null, [
334
+ el("td", null, [
335
+ dot(
336
+ f.level === "error" ? "bad" : f.level === "warn" ? "warn" : "unknown",
337
+ String(f.level),
338
+ ),
339
+ ]),
340
+ el("td", { class: "mono", text: String(f.name) }),
341
+ el("td", { class: "mono", text: String(f.version ?? "—") }),
342
+ el("td", { text: String(f.finding) }),
343
+ ]),
344
+ ),
345
+ ),
346
+ );
347
+ });
348
+
349
+ const lint = el("button", {
350
+ class: "btn btn-ghost",
351
+ type: "button",
352
+ text: "Scan for contamination",
353
+ });
354
+ lint.addEventListener("click", async () => {
355
+ lint.disabled = true;
356
+ const res = await call(api.datasetLint, { id: ctx.id }, {});
357
+ lint.disabled = false;
358
+ if (!res.ok) {
359
+ toast(String(payload(res).error ?? `HTTP ${String(res.status)}`), "error");
360
+ return;
361
+ }
362
+ toast("Contamination scan queued — watch the job feed", "info");
363
+ });
364
+
365
+ card.appendChild(el("div", { class: "editor-actions" }, [audit, lint]));
366
+ card.appendChild(out);
367
+ return card;
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // growth — preview, THEN write
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /**
375
+ * Each growth verb adds rows to a dataset that later gates a release, so the
376
+ * default is a preview: what would run, where it would write, and what
377
+ * review still stands between the output and a gating dataset.
378
+ */
379
+ function growthCard(ctx, reload) {
380
+ const out = el("div");
381
+ const form = el("form", { class: "row-editor" }, [
382
+ field("dataset", input("name", { placeholder: "dataset name", required: true })),
383
+ field(
384
+ "verb",
385
+ el(
386
+ "select",
387
+ { class: "input", name: "verb" },
388
+ [
389
+ ["mine", "mine hard cases from sessions"],
390
+ ["synthesize", "synthesize stress variants"],
391
+ ["refresh-goldens", "refresh goldens"],
392
+ ].map(([value, label]) => el("option", { value, text: label })),
393
+ ),
394
+ ),
395
+ ]);
396
+ const preview = el("button", { class: "btn", type: "submit", text: "Preview" });
397
+ form.appendChild(el("div", { class: "editor-actions" }, [preview]));
398
+
399
+ const verbApi = {
400
+ mine: api.datasetMine,
401
+ synthesize: api.datasetSynthesize,
402
+ "refresh-goldens": api.datasetRefreshGoldens,
403
+ };
404
+
405
+ form.addEventListener("submit", async (event) => {
406
+ event.preventDefault();
407
+ const name = readText(form, "name");
408
+ const verb = form.elements["verb"].value;
409
+ preview.disabled = true;
410
+ const res = await call(verbApi[verb], { id: ctx.id }, { name, dryRun: true });
411
+ preview.disabled = false;
412
+ clear(out);
413
+ if (!res.ok) {
414
+ toast(String(payload(res).error ?? `HTTP ${String(res.status)}`), "error");
415
+ return;
416
+ }
417
+ const data = payload(res);
418
+ const plan = data.plan ?? {};
419
+ out.appendChild(el("p", { class: "muted", text: String(data.note ?? "") }));
420
+ out.appendChild(el("p", { class: "gated-why", text: String(plan.reviewGate ?? "") }));
421
+ out.appendChild(
422
+ collapsible(
423
+ [el("span", { class: "muted", text: "what this will run and where it writes" })],
424
+ [jsonPre({ argv: plan.argv ?? [], writes: plan.writes ?? [] })],
425
+ true,
426
+ ),
427
+ );
428
+ const run = el("button", {
429
+ class: "btn btn-danger",
430
+ type: "button",
431
+ text: `Run ${verb} for real`,
432
+ });
433
+ run.addEventListener("click", async () => {
434
+ run.disabled = true;
435
+ const applied = await call(verbApi[verb], { id: ctx.id }, { name, dryRun: false });
436
+ run.disabled = false;
437
+ if (!applied.ok) {
438
+ toast(String(payload(applied).error ?? `HTTP ${String(applied.status)}`), "error");
439
+ return;
440
+ }
441
+ toast(`${verb} queued`, "info");
442
+ if (reload) reload();
443
+ });
444
+ out.appendChild(el("div", { class: "editor-actions" }, [run]));
445
+ });
446
+
447
+ return el("div", { class: "card" }, [
448
+ el("h3", { class: "card-title" }, [el("span", { text: "Grow a dataset" })]),
449
+ el("p", {
450
+ class: "muted",
451
+ text: "Mining stages candidates in quarantine; synthetic rows never become gold; refreshing goldens changes what correct means. Each previews first.",
452
+ }),
453
+ form,
454
+ out,
455
+ ]);
456
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Deployments — the records a harness has written to
3
+ * `.crewhaus/deployments.json`, with their class (local / PaaS / cf-worker)
4
+ * and health.
5
+ *
6
+ * HONESTLY EMPTY IS THE NORMAL ANSWER. Nothing writes that file yet, and the
7
+ * manager deliberately does not invent a writer: a console that "knows"
8
+ * about a deploy it did not perform — and misses every deploy performed from
9
+ * a terminal — is worse than one that says the file is absent and points at
10
+ * where it will appear. So the empty state names the path, not a failure.
11
+ */
12
+
13
+ import { api } from "../api.js";
14
+ import { clear, copyBtn, dot, el, emptyState, jsonPre, skeleton } from "../dom.js";
15
+ import { deploymentRow } from "../supervision.js";
16
+
17
+ export async function renderDeploy(root, ctx) {
18
+ clear(root).appendChild(skeleton(4));
19
+ const view = await api.deployments(ctx.id);
20
+ clear(root);
21
+ if (view === null) {
22
+ root.appendChild(emptyState("Nothing here yet — no deployment records"));
23
+ return;
24
+ }
25
+ const nowMs = Date.now();
26
+ const records = Array.isArray(view.deployments) ? view.deployments : [];
27
+ const path = typeof view.path === "string" ? view.path : "";
28
+
29
+ if (view.error !== null && view.error !== undefined) {
30
+ root.appendChild(
31
+ el("div", { class: "card error-card" }, [
32
+ el("h3", { class: "card-title" }, [dot("bad", "deployments.json is unreadable")]),
33
+ el("p", { class: "muted", text: String(view.error) }),
34
+ el("div", { class: "mono muted", text: path }),
35
+ ]),
36
+ );
37
+ return;
38
+ }
39
+
40
+ if (records.length === 0) {
41
+ root.appendChild(
42
+ el("div", { class: "card" }, [
43
+ el("h3", { class: "card-title" }, [
44
+ el("span", { text: "Deployments" }),
45
+ dot("off", view.present === true ? "file present, no records" : "nothing recorded"),
46
+ ]),
47
+ emptyState(
48
+ typeof view.note === "string" && view.note !== ""
49
+ ? view.note
50
+ : "No deployment records yet",
51
+ ),
52
+ el("div", { class: "h-dir" }, [
53
+ el("span", { class: "mono muted", title: path, text: path }),
54
+ path !== "" ? copyBtn(path, "copy path") : null,
55
+ ]),
56
+ ]),
57
+ );
58
+ return;
59
+ }
60
+
61
+ const tbody = el("tbody");
62
+ for (const record of records) {
63
+ const row = deploymentRow(record, nowMs);
64
+ tbody.appendChild(
65
+ el("tr", null, [
66
+ el("td", { text: row.env }),
67
+ el("td", null, [el("span", { class: "chip", text: row.klass })]),
68
+ el("td", { class: "mono", text: row.provider ?? "—" }),
69
+ el("td", { class: "mono", text: row.version ?? "—" }),
70
+ el("td", { text: row.when }),
71
+ el("td", null, [dot(row.dot, row.healthLabel)]),
72
+ el("td", null, [
73
+ row.url !== null
74
+ ? el("a", { class: "mono", href: row.url, rel: "noreferrer", text: row.url })
75
+ : el("span", { class: "muted", text: "—" }),
76
+ ]),
77
+ ]),
78
+ );
79
+ }
80
+ root.appendChild(
81
+ el("div", { class: "table-scroll" }, [
82
+ el("table", { class: "fleet" }, [
83
+ el(
84
+ "thead",
85
+ null,
86
+ el(
87
+ "tr",
88
+ null,
89
+ ["Env", "Class", "Provider", "Version", "When", "Health", "URL"].map((h) =>
90
+ el("th", { text: h }),
91
+ ),
92
+ ),
93
+ ),
94
+ tbody,
95
+ ]),
96
+ ]),
97
+ );
98
+ root.appendChild(
99
+ el("details", { class: "fold" }, [
100
+ el("summary", { class: "fold-summary" }, [
101
+ el("span", { class: "muted", text: `raw records — ${path}` }),
102
+ ]),
103
+ el("div", { class: "fold-body" }, [jsonPre(records)]),
104
+ ]),
105
+ );
106
+ }