@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,228 @@
1
+ /**
2
+ * Memory tab — the fabric, read-only, rendered from the server's actual
3
+ * view shapes: facts `{files:[{items,…}]}` (status folded server-side from
4
+ * tombstones), wiki `{index, articles: [slug]}`, continuity
5
+ * `{focus, goals, plans:[{file,text}]}`, dream `{specs:[{specName,state}]}`,
6
+ * and watchme `{state, observationsTail, judgmentsTail}`. Each area loads
7
+ * independently so one absent store never hides another ("absence is not
8
+ * an error").
9
+ */
10
+
11
+ import { api } from "../api.js";
12
+ import { clear, collapsible, dot, el, emptyState, jsonPre, mdBlocks, skeleton } from "../dom.js";
13
+ import { hrefHarness } from "../router.js";
14
+ import { fmtRelativeTime } from "../util.js";
15
+ import { jobButton } from "./jobs.js";
16
+
17
+ export async function renderMemory(root, ctx) {
18
+ if (ctx.route.wikiSlug !== undefined) {
19
+ await renderWikiArticle(root, ctx, ctx.route.wikiSlug);
20
+ return;
21
+ }
22
+ clear(root).appendChild(skeleton(8));
23
+ const [facts, wiki, continuity, dream, watchme] = await Promise.allSettled([
24
+ api.memory(ctx.id, "facts"),
25
+ api.memory(ctx.id, "wiki"),
26
+ api.memory(ctx.id, "state"),
27
+ api.memory(ctx.id, "dream"),
28
+ api.memory(ctx.id, "watchme"),
29
+ ]);
30
+ clear(root);
31
+ const grid = el("div", { class: "ov-grid" });
32
+ grid.appendChild(factsCard(value(facts)));
33
+ grid.appendChild(wikiCard(value(wiki), ctx));
34
+ grid.appendChild(continuityCard(value(continuity)));
35
+ grid.appendChild(dreamCard(value(dream), ctx, () => renderMemory(root, ctx)));
36
+ grid.appendChild(watchmeCard(value(watchme)));
37
+ root.appendChild(grid);
38
+ }
39
+
40
+ function value(res) {
41
+ return res.status === "fulfilled" ? res.value : null;
42
+ }
43
+
44
+ function card(title, children, wide = false) {
45
+ return el("section", { class: `card ov-card${wide ? " ov-wide" : ""}` }, [
46
+ el("h3", { class: "card-title", text: title }),
47
+ ...(Array.isArray(children) ? children : [children]),
48
+ ]);
49
+ }
50
+
51
+ function factsCard(data) {
52
+ // One entry per memories/<spec>.jsonl file; items carry a folded status.
53
+ const files = Array.isArray(data?.files) ? data.files : [];
54
+ const items = files.flatMap((f) =>
55
+ Array.isArray(f?.items)
56
+ ? f.items.map((i) => ({ ...i, specName: String(f.specName ?? "") }))
57
+ : [],
58
+ );
59
+ if (items.length === 0) {
60
+ return card("Facts", emptyState("No facts yet", "crewhaus remember"));
61
+ }
62
+ const live = items.filter((i) => i.status === "live");
63
+ const folded = items.filter((i) => i.status !== "live");
64
+ const factLi = (f) =>
65
+ el("li", { class: "fact" }, [
66
+ el("div", { text: String(f.text ?? "") }),
67
+ el("div", { class: "fact-meta" }, [
68
+ ...(Array.isArray(f.tags) ? f.tags : []).map((t) =>
69
+ el("span", { class: "chip", text: String(t) }),
70
+ ),
71
+ f.status !== "live"
72
+ ? el("span", { class: "chip chip-warn", text: String(f.status) })
73
+ : null,
74
+ ]),
75
+ ]);
76
+ const children = [el("ul", { class: "fact-list" }, live.map(factLi))];
77
+ if (folded.length > 0) {
78
+ children.push(
79
+ collapsible(
80
+ [
81
+ el("span", {
82
+ class: "muted",
83
+ text: `${folded.length} superseded/expired fact${folded.length === 1 ? "" : "s"} (tombstoned, never deleted)`,
84
+ }),
85
+ ],
86
+ [el("ul", { class: "fact-list folded" }, folded.map(factLi))],
87
+ ),
88
+ );
89
+ }
90
+ if (files.some((f) => f?.truncated === true)) {
91
+ children.push(
92
+ el("p", { class: "muted", text: "Long store — showing the first entries only." }),
93
+ );
94
+ }
95
+ return card("Facts", children, true);
96
+ }
97
+
98
+ function wikiCard(data, ctx) {
99
+ // Articles are slugs (the on-disk authority); titles come from the
100
+ // tolerant index.json cache when it has them.
101
+ const slugs = Array.isArray(data?.articles) ? data.articles.map(String) : [];
102
+ if (slugs.length === 0) {
103
+ return card("Wiki", emptyState("No wiki articles yet", "crewhaus wiki write"));
104
+ }
105
+ const index = data?.index && typeof data.index === "object" ? data.index : {};
106
+ return card(
107
+ "Wiki",
108
+ el(
109
+ "ul",
110
+ { class: "wiki-list" },
111
+ slugs.map((slug) => {
112
+ const meta = index[slug] && typeof index[slug] === "object" ? index[slug] : {};
113
+ return el("li", null, [
114
+ el("a", {
115
+ class: "name-link",
116
+ href: hrefHarness(ctx.id, "memory", "wiki", slug),
117
+ text: typeof meta.title === "string" && meta.title !== "" ? meta.title : slug,
118
+ }),
119
+ ...(Array.isArray(meta.tags) ? meta.tags : []).map((t) =>
120
+ el("span", { class: "chip", text: String(t) }),
121
+ ),
122
+ ]);
123
+ }),
124
+ ),
125
+ );
126
+ }
127
+
128
+ async function renderWikiArticle(root, ctx, slug) {
129
+ clear(root).appendChild(skeleton(6));
130
+ const data = await api.wikiArticle(ctx.id, slug);
131
+ clear(root);
132
+ root.appendChild(
133
+ el("div", { class: "crumb-line" }, [
134
+ el("a", { href: hrefHarness(ctx.id, "memory"), text: "← memory" }),
135
+ el("span", { class: "mono muted", text: slug }),
136
+ ]),
137
+ );
138
+ if (data === null) {
139
+ root.appendChild(emptyState("Nothing here yet — no article with that slug"));
140
+ return;
141
+ }
142
+ root.appendChild(
143
+ el("article", { class: "card wiki-article" }, [
144
+ el("h2", { text: slug }),
145
+ data.truncated === true
146
+ ? el("p", { class: "muted", text: "Long article — showing the first part only." })
147
+ : null,
148
+ mdBlocks(String(data.body ?? "")),
149
+ ]),
150
+ );
151
+ }
152
+
153
+ function continuityCard(data) {
154
+ const focus = typeof data?.focus === "string" ? data.focus : "";
155
+ const goals = typeof data?.goals === "string" ? data.goals : "";
156
+ const plans = Array.isArray(data?.plans) ? data.plans : [];
157
+ if (focus === "" && goals === "" && plans.length === 0) {
158
+ return card("Continuity", emptyState("No continuity state yet", "crewhaus /focus (or /plan)"));
159
+ }
160
+ const children = [];
161
+ if (focus !== "") {
162
+ children.push(el("h4", { class: "sub-title", text: "Focus" }));
163
+ children.push(el("pre", { class: "prose-pre", text: focus }));
164
+ }
165
+ if (goals !== "") {
166
+ children.push(el("h4", { class: "sub-title", text: "Goals" }));
167
+ children.push(el("pre", { class: "prose-pre", text: goals }));
168
+ }
169
+ for (const plan of plans) {
170
+ children.push(
171
+ collapsible(
172
+ [el("span", { class: "mono", text: String(plan.file ?? "plan") })],
173
+ [el("pre", { class: "prose-pre", text: String(plan.text ?? "") })],
174
+ ),
175
+ );
176
+ }
177
+ return card("Continuity", children);
178
+ }
179
+
180
+ function dreamCard(data, ctx, reload) {
181
+ const specs = Array.isArray(data?.specs) ? data.specs : [];
182
+ // Dream is a read-only timer row in control.v1 — there is nothing to poke,
183
+ // so "Run now" goes through the job queue, exactly as the CLI would.
184
+ const runNow = jobButton("Run now", ctx.id, "dream-run", undefined, reload);
185
+ if (specs.length === 0) {
186
+ return card("Dream", [emptyState("No dream state yet", "crewhaus dream run"), runNow]);
187
+ }
188
+ const children = specs.map((s) => {
189
+ const state = s?.state && typeof s.state === "object" ? s.state : {};
190
+ const lastRunAt = typeof state.lastRunAt === "string" ? state.lastRunAt : null;
191
+ return collapsible(
192
+ [
193
+ el("span", { class: "mono", text: String(s.specName ?? "spec") }),
194
+ el("span", {
195
+ class: "muted",
196
+ text: lastRunAt ? ` last ran ${fmtRelativeTime(lastRunAt, Date.now())}` : "",
197
+ }),
198
+ ],
199
+ [jsonPre(state)],
200
+ );
201
+ });
202
+ children.push(runNow);
203
+ return card("Dream", children);
204
+ }
205
+
206
+ function watchmeCard(data) {
207
+ const state = data?.state && typeof data.state === "object" ? data.state : null;
208
+ if (state === null) {
209
+ return card("Watchme", emptyState("Not watching yet", "crewhaus watchme start"));
210
+ }
211
+ const watching = state.watching === true || state.active === true;
212
+ const observations = Array.isArray(data.observationsTail) ? data.observationsTail.length : 0;
213
+ const judgments = Array.isArray(data.judgmentsTail) ? data.judgmentsTail.length : 0;
214
+ return card("Watchme", [
215
+ el("div", { class: "kv" }, [
216
+ el("span", { class: "kv-k", text: "state" }),
217
+ el("span", { class: "kv-v" }, [dot(watching ? "ok" : "off", watching ? "watching" : "off")]),
218
+ ]),
219
+ el("div", { class: "kv" }, [
220
+ el("span", { class: "kv-k", text: "recent observations" }),
221
+ el("span", { class: "kv-v", text: String(observations) }),
222
+ ]),
223
+ el("div", { class: "kv" }, [
224
+ el("span", { class: "kv-k", text: "recent judgments" }),
225
+ el("span", { class: "kv-v", text: String(judgments) }),
226
+ ]),
227
+ ]);
228
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * HM-12 — first boot.
3
+ *
4
+ * An empty Library looks broken and teaches nothing, so the Library hands
5
+ * over to this screen when there is nothing registered and no scan root
6
+ * configured. Two paths out, both showing the command they run:
7
+ *
8
+ * - point the manager at a directory and scan it;
9
+ * - install a demo starter out of a local demos checkout.
10
+ *
11
+ * Demo mode is honest about needing that checkout. With none configured the
12
+ * card says so and names the repo, the environment variable and the CLI verb
13
+ * — which teaches more than a spinner that ends in a network error would.
14
+ */
15
+
16
+ import { api } from "../api.js";
17
+ import { clear, copyBtn, dot, el, emptyState, skeleton, toast } from "../dom.js";
18
+
19
+ /** One "here is the command" line — the CLI-twin discipline, on a screen an
20
+ * operator sees before they have run anything. */
21
+ function twin(command) {
22
+ return el("div", { class: "twin" }, [
23
+ el("code", { class: "mono", text: command }),
24
+ copyBtn(command),
25
+ ]);
26
+ }
27
+
28
+ export async function renderOnboarding(root, opts = {}) {
29
+ const reload = opts.reload ?? (() => {});
30
+ clear(root).appendChild(skeleton(5));
31
+ let view = null;
32
+ try {
33
+ view = await api.onboarding();
34
+ } catch (err) {
35
+ clear(root).appendChild(
36
+ el("div", { class: "card error-card" }, [
37
+ el("h2", { text: "Could not read the first-boot state" }),
38
+ el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
39
+ ]),
40
+ );
41
+ return;
42
+ }
43
+ clear(root);
44
+ const twins = view?.cliTwins && typeof view.cliTwins === "object" ? view.cliTwins : {};
45
+ root.appendChild(
46
+ el("div", { class: "card" }, [
47
+ el("h2", { text: "Welcome to Hangar" }),
48
+ el("p", {
49
+ class: "muted",
50
+ text: "Hangar manages the harnesses on this machine. Point it at a directory, or install a demo — either way it registers what it finds and nothing else.",
51
+ }),
52
+ ]),
53
+ );
54
+ root.appendChild(scanRootCard(view, twins, reload));
55
+ root.appendChild(demoCard(view, twins, reload));
56
+ root.appendChild(manualCard(twins, reload));
57
+ }
58
+
59
+ function scanRootCard(view, twins, reload) {
60
+ const suggestions = Array.isArray(view?.suggestions) ? view.suggestions : [];
61
+ const card = el("section", { class: "card" }, [
62
+ el("h3", { class: "card-title", text: "1 · Where do your harnesses live?" }),
63
+ el("p", {
64
+ class: "muted",
65
+ text: "A scan root is walked for crewhaus.yaml files. Adding one registers what it finds; it never writes inside a harness.",
66
+ }),
67
+ ]);
68
+ if (suggestions.length === 0) {
69
+ card.appendChild(emptyState("No suggestions on this machine", twins.addScanRoot));
70
+ } else {
71
+ card.appendChild(
72
+ el(
73
+ "ul",
74
+ { class: "check-list" },
75
+ suggestions.map((s) =>
76
+ el("li", { class: "suggest-row" }, [
77
+ dot(s?.exists ? "ok" : "unknown", s?.exists ? "exists" : "not created yet"),
78
+ el("code", { class: "mono", text: String(s?.dir ?? "") }),
79
+ el("span", { class: "muted", text: String(s?.why ?? "") }),
80
+ el("button", {
81
+ class: "btn",
82
+ type: "button",
83
+ text: "Add + scan",
84
+ onClick: async (event) => {
85
+ const button = event.currentTarget;
86
+ button.disabled = true;
87
+ try {
88
+ await api.addScanRoot(String(s?.dir ?? ""));
89
+ const result = await api.scan();
90
+ toast(
91
+ `Scanned: ${result?.discovered ?? 0} harness(es) found, ${result?.added ?? 0} newly registered`,
92
+ "info",
93
+ );
94
+ reload();
95
+ } catch (err) {
96
+ button.disabled = false;
97
+ toast(err instanceof Error ? err.message : String(err), "error");
98
+ }
99
+ },
100
+ }),
101
+ ]),
102
+ ),
103
+ ),
104
+ );
105
+ }
106
+ const field = el("input", {
107
+ class: "input mono grow",
108
+ type: "text",
109
+ placeholder: "/an/absolute/path",
110
+ "aria-label": "scan root path",
111
+ });
112
+ card.appendChild(
113
+ el("div", { class: "add-form" }, [
114
+ field,
115
+ el("button", {
116
+ class: "btn",
117
+ type: "button",
118
+ text: "Add this root",
119
+ onClick: async () => {
120
+ const dir = field.value.trim();
121
+ if (dir === "") return;
122
+ try {
123
+ await api.addScanRoot(dir);
124
+ await api.scan();
125
+ reload();
126
+ } catch (err) {
127
+ toast(err instanceof Error ? err.message : String(err), "error");
128
+ }
129
+ },
130
+ }),
131
+ ]),
132
+ );
133
+ // `harness scan --root <dir>` is the real twin for this card's two
134
+ // buttons: the CLI remembers the root it was handed before it walks it,
135
+ // which is exactly "add + scan". There is no `harness scan-root` verb.
136
+ card.appendChild(twin(twins.addScanRoot ?? "crewhaus harness scan --root <dir>"));
137
+ card.appendChild(twin(twins.scan ?? "crewhaus harness scan"));
138
+ return card;
139
+ }
140
+
141
+ function demoCard(view, twins, reload) {
142
+ const demo = view?.demo && typeof view.demo === "object" ? view.demo : {};
143
+ const card = el("section", { class: "card" }, [
144
+ el("h3", { class: "card-title", text: "2 · Or start from a demo" }),
145
+ ]);
146
+ if (demo.available !== true) {
147
+ card.appendChild(
148
+ el("div", { class: "empty" }, [
149
+ dot("unknown", "demo mode is unavailable"),
150
+ el("div", { class: "empty-msg", text: String(demo.reason ?? "no demos checkout found") }),
151
+ el("div", { class: "empty-verb muted", text: String(demo.remedy ?? "") }),
152
+ ]),
153
+ );
154
+ card.appendChild(twin("git clone https://github.com/crewhaus/demos"));
155
+ card.appendChild(twin("crewhaus init <dir>"));
156
+ return card;
157
+ }
158
+ const starters = Array.isArray(demo.starters) ? demo.starters : [];
159
+ const select = el(
160
+ "select",
161
+ { class: "input", "aria-label": "starter" },
162
+ starters.map((name) => el("option", { value: name, text: name })),
163
+ );
164
+ const dest = el("input", {
165
+ class: "input mono grow",
166
+ type: "text",
167
+ placeholder: "/an/absolute/path/for/the/demo",
168
+ "aria-label": "install directory",
169
+ });
170
+ card.appendChild(
171
+ el("p", {
172
+ class: "muted",
173
+ text: `Copies one starter out of ${String(demo.source ?? "the demos checkout")} into a new directory and registers it. Nothing is downloaded, and an existing directory is never written over.`,
174
+ }),
175
+ );
176
+ card.appendChild(
177
+ el("div", { class: "add-form" }, [
178
+ select,
179
+ dest,
180
+ el("button", {
181
+ class: "btn btn-primary",
182
+ type: "button",
183
+ text: "Install demo",
184
+ onClick: async (event) => {
185
+ const button = event.currentTarget;
186
+ const dir = dest.value.trim();
187
+ if (dir === "") {
188
+ toast("Give an absolute path for the demo", "error");
189
+ return;
190
+ }
191
+ button.disabled = true;
192
+ const res = await api.demoInstall(select.value, dir);
193
+ button.disabled = false;
194
+ if (res.ok !== true) {
195
+ toast(
196
+ `${String(res.body?.message ?? "refused")} — ${String(res.body?.remedy ?? "")}`,
197
+ "error",
198
+ );
199
+ return;
200
+ }
201
+ toast(`Installed ${select.value} into ${dir}`, "info");
202
+ reload();
203
+ },
204
+ }),
205
+ ]),
206
+ );
207
+ card.appendChild(twin(twins.demo ?? "cp -R <demos>/starters/<starter> <dir>"));
208
+ return card;
209
+ }
210
+
211
+ function manualCard(twins, reload) {
212
+ const field = el("input", {
213
+ class: "input mono grow",
214
+ type: "text",
215
+ placeholder: "/path/to/an/existing/harness",
216
+ "aria-label": "harness directory",
217
+ });
218
+ return el("section", { class: "card" }, [
219
+ el("h3", { class: "card-title", text: "3 · Already have one? Register it directly" }),
220
+ el("div", { class: "add-form" }, [
221
+ field,
222
+ el("button", {
223
+ class: "btn",
224
+ type: "button",
225
+ text: "Register",
226
+ onClick: async () => {
227
+ const dir = field.value.trim();
228
+ if (dir === "") return;
229
+ try {
230
+ await api.addHarness(dir);
231
+ reload();
232
+ } catch (err) {
233
+ toast(err instanceof Error ? err.message : String(err), "error");
234
+ }
235
+ },
236
+ }),
237
+ ]),
238
+ twin(twins.addHarness ?? "crewhaus harness add <dir>"),
239
+ ]);
240
+ }
241
+
242
+ /**
243
+ * Should the Library hand over to onboarding? Pure, so the rule is testable:
244
+ * only when there is genuinely nothing — no registered harness AND no scan
245
+ * root. A machine with a root configured but nothing found is NOT first
246
+ * boot; it is an empty scan, and the Library's own empty state (which names
247
+ * the scan verb) is the honest answer there.
248
+ */
249
+ export function shouldOnboard(view, harnessCount) {
250
+ if (harnessCount > 0) return false;
251
+ if (!view || typeof view !== "object") return false;
252
+ return view.firstBoot === true;
253
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Harness detail — Overview tab: the process card (M2's Start/Stop/Restart/
3
+ * Drain, with the preflight-refusal flow), health checklist, eval trend
4
+ * mini-line, memory-fabric mini-cards, cost mini-chart, and the expandable
5
+ * preflight report. Every absent payload renders as "nothing yet".
6
+ */
7
+
8
+ import { api } from "../api.js";
9
+ import { clear, collapsible, dot, el, emptyState, skeleton, svgEl } from "../dom.js";
10
+ import {
11
+ barRects,
12
+ fmtCount,
13
+ fmtPct,
14
+ fmtUsd,
15
+ healthChecks,
16
+ sparklinePath,
17
+ usdFromMicros,
18
+ } from "../util.js";
19
+ import { procCard } from "./proc.js";
20
+
21
+ export async function renderOverview(root, ctx) {
22
+ clear(root).appendChild(skeleton(5));
23
+ const [preflightRes, evalsRes, costsRes] = await Promise.allSettled([
24
+ api.preflight(ctx.id),
25
+ api.evals(ctx.id),
26
+ api.costs(ctx.id),
27
+ ]);
28
+ clear(root);
29
+ // The process picture came with the page load — render it without a
30
+ // second fetch, at the top, because it is the only card with verbs.
31
+ if (ctx.proc) root.appendChild(procCard(ctx.proc, ctx, ctx.reload ?? (() => {})));
32
+ const grid = el("div", { class: "ov-grid" });
33
+ grid.appendChild(healthCard(ctx.detail));
34
+ grid.appendChild(evalTrendCard(settled(evalsRes)));
35
+ grid.appendChild(memoryCard(ctx.detail));
36
+ grid.appendChild(costCard(settled(costsRes)));
37
+ root.appendChild(grid);
38
+ root.appendChild(preflightCard(settled(preflightRes)));
39
+ }
40
+
41
+ function settled(res) {
42
+ return res.status === "fulfilled" ? res.value : null;
43
+ }
44
+
45
+ function card(title, children) {
46
+ return el("section", { class: "card ov-card" }, [
47
+ el("h3", { class: "card-title", text: title }),
48
+ ...(Array.isArray(children) ? children : [children]),
49
+ ]);
50
+ }
51
+
52
+ function healthCard(detail) {
53
+ // The detail payload's `health` is the server's HarnessHealth record;
54
+ // `healthChecks` maps its fields into checklist rows.
55
+ const checks = healthChecks(detail?.health);
56
+ if (checks.length === 0) {
57
+ return card("Health", emptyState("No health data yet", "crewhaus doctor"));
58
+ }
59
+ return card(
60
+ "Health",
61
+ el(
62
+ "ul",
63
+ { class: "check-list" },
64
+ checks.map((c) =>
65
+ el("li", null, [
66
+ dot(c.state, c.label),
67
+ c.reason ? el("span", { class: "muted reason", text: c.reason }) : null,
68
+ ]),
69
+ ),
70
+ ),
71
+ );
72
+ }
73
+
74
+ function evalTrendCard(evals) {
75
+ const runs = Array.isArray(evals) ? evals : Array.isArray(evals?.runs) ? evals.runs : [];
76
+ if (runs.length === 0) {
77
+ return card("Eval trend", emptyState("No evals yet", "crewhaus eval"));
78
+ }
79
+ const chronological = [...runs].reverse();
80
+ const rates = chronological
81
+ .map((r) => (typeof r.passRate === "number" ? r.passRate : null))
82
+ .filter((v) => v !== null)
83
+ .slice(-12);
84
+ const latest = rates.length > 0 ? rates[rates.length - 1] : null;
85
+ const path = sparklinePath(rates, 220, 44);
86
+ const children = [
87
+ el("div", { class: "big-stat" }, [
88
+ el("span", { class: "stat-num", text: fmtPct(latest) }),
89
+ el("span", { class: "muted", text: ` latest pass rate · ${runs.length} runs` }),
90
+ ]),
91
+ ];
92
+ if (path !== "") {
93
+ children.push(
94
+ svgEl(
95
+ "svg",
96
+ { class: "spark", viewBox: "0 0 220 44", role: "img", "aria-label": "pass-rate trend" },
97
+ [svgEl("path", { d: path, fill: "none", "stroke-width": "2" })],
98
+ ),
99
+ );
100
+ }
101
+ return card("Eval trend", children);
102
+ }
103
+
104
+ function memoryCard(detail) {
105
+ // The detail payload carries small counts only: { facts, articles }.
106
+ const mem = detail?.memory && typeof detail.memory === "object" ? detail.memory : null;
107
+ if (mem === null) {
108
+ return card("Memory fabric", emptyState("No memory data yet", "crewhaus remember"));
109
+ }
110
+ const mini = (label, value) =>
111
+ el("div", { class: "mini" }, [
112
+ el("div", { class: "mini-num", text: value }),
113
+ el("div", { class: "mini-label", text: label }),
114
+ ]);
115
+ return card(
116
+ "Memory fabric",
117
+ el("div", { class: "mini-row" }, [
118
+ mini("live facts", fmtCount(mem.facts)),
119
+ mini("wiki articles", fmtCount(mem.articles)),
120
+ ]),
121
+ );
122
+ }
123
+
124
+ function costCard(payload) {
125
+ // `/api/h/:id/costs` returns { id, costs: { spend7dUsdMicros, days, … } }.
126
+ const costs = payload?.costs && typeof payload.costs === "object" ? payload.costs : null;
127
+ const days = Array.isArray(costs?.days) ? costs.days : [];
128
+ if (costs === null || (costs.calls === 0 && days.every((d) => d.usdMicros === 0))) {
129
+ return card(
130
+ "Spend (7d)",
131
+ emptyState("No cost data yet", "crewhaus run (cost tracking is on by default)"),
132
+ );
133
+ }
134
+ const values = days.map((d) => usdFromMicros(d.usdMicros) ?? 0);
135
+ const total = usdFromMicros(costs.spend7dUsdMicros);
136
+ const rects = barRects(values.slice(-7), 220, 44);
137
+ return card("Spend (7d)", [
138
+ el("div", { class: "big-stat" }, [el("span", { class: "stat-num", text: fmtUsd(total) })]),
139
+ svgEl(
140
+ "svg",
141
+ { class: "bars", viewBox: "0 0 220 44", role: "img", "aria-label": "daily spend bars" },
142
+ rects.map((r) => svgEl("rect", { x: r.x, y: r.y, width: r.w, height: r.h, rx: "1" })),
143
+ ),
144
+ ]);
145
+ }
146
+
147
+ const LEVEL_META = {
148
+ info: { mark: "✓", state: "ok" },
149
+ warn: { mark: "⚠", state: "warn" },
150
+ blocking: { mark: "✗", state: "bad" },
151
+ };
152
+
153
+ function preflightCard(payload) {
154
+ // The preflight route returns { report, envFiles }; tolerate a bare report.
155
+ const report = payload?.report && typeof payload.report === "object" ? payload.report : payload;
156
+ const items = Array.isArray(report?.items) ? report.items : [];
157
+ if (items.length === 0) {
158
+ return card("Preflight", emptyState("No preflight report yet", "crewhaus harness preflight"));
159
+ }
160
+ const warns = items.filter((i) => i.level === "warn").length;
161
+ const blocking = items.filter((i) => i.level === "blocking").length;
162
+ const summaryLine = `${items.length} checks · ${warns} warning${warns === 1 ? "" : "s"} · ${blocking} blocking`;
163
+ const list = el(
164
+ "div",
165
+ { class: "preflight-list" },
166
+ items.map((item) => {
167
+ const meta = LEVEL_META[item.level] ?? LEVEL_META.info;
168
+ return collapsible(
169
+ [
170
+ el("span", { class: `pf-mark pf-${meta.state}`, "aria-hidden": "true", text: meta.mark }),
171
+ el("span", { class: "pf-level", text: item.level ?? "info" }),
172
+ el("span", { class: "pf-area muted", text: item.area ?? "" }),
173
+ el("span", { class: "pf-msg", text: item.message ?? "" }),
174
+ ],
175
+ [
176
+ item.remediation
177
+ ? el("p", null, [el("strong", { text: "Fix: " }), item.remediation])
178
+ : el("p", { class: "muted", text: "No remediation recorded." }),
179
+ item.envVar ? el("p", { class: "mono muted", text: `env: ${item.envVar}` }) : null,
180
+ ],
181
+ item.level === "blocking",
182
+ );
183
+ }),
184
+ );
185
+ return el("section", { class: "card ov-card ov-wide" }, [
186
+ el("h3", { class: "card-title" }, [
187
+ el("span", { text: "Preflight" }),
188
+ el("span", { class: "muted card-sub", text: summaryLine }),
189
+ dot(
190
+ blocking > 0 ? "bad" : warns > 0 ? "warn" : "ok",
191
+ blocking > 0 ? "will not boot" : warns > 0 ? "degraded" : "ready",
192
+ ),
193
+ ]),
194
+ list,
195
+ ]);
196
+ }