@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,489 @@
1
+ /**
2
+ * HM-198's console half — Settings: notification rules (HM-183), read-only
3
+ * mode (HM-187), and the installed-plugin inventory (HM-179).
4
+ *
5
+ * Three deliberate choices show up here as UI:
6
+ *
7
+ * - Every rule ships OFF except parked approvals, and the screen says so,
8
+ * because a product that decides what deserves to interrupt you is a
9
+ * product you stop trusting.
10
+ * - The read-only toggle is labelled with what it actually protects. It
11
+ * prevents accidents during a demo; the bearer token is the security
12
+ * boundary. Claiming more would be a lie a screen-share could disprove.
13
+ * - The plugin table lists the DEFERRED extension points beside the wired
14
+ * ones, with their reasons. A capability that is declared but not wired
15
+ * has to be visible, or the next person wires it a second time.
16
+ *
17
+ * This screen does NOT read `GET /api/notifications` itself. That GET is the
18
+ * rules evaluation (HM-183), so a second caller consumes deliveries the app's
19
+ * poll would otherwise have toasted — the state comes from the one shared
20
+ * poll in `notify.js`, and the PUT's answer is folded back into it.
21
+ */
22
+
23
+ import { api } from "../api.js";
24
+ import { clear, dot, el, emptyState, skeleton, toast } from "../dom.js";
25
+ import { notifications } from "../notify.js";
26
+ import { hrefHarness } from "../router.js";
27
+ import { fmtRelativeTime } from "../util.js";
28
+
29
+ /** kind → the sentence the rule row shows. Mirrors the server's KIND_LABELS;
30
+ * an unknown kind falls back to its slug rather than disappearing. */
31
+ const KIND_LABELS = {
32
+ "approval-parked": "an approval is parked on a human",
33
+ "exit-30": "a run exited 30 (policy refusal)",
34
+ "exit-31": "a run exited 31 (provider funding)",
35
+ "exit-33": "a run exited 33 (unrecoverable config)",
36
+ "eval-gate-failed": "an eval gate failed against its baseline",
37
+ "incident-opened": "an incident was opened",
38
+ "dream-overdue": "a dream is overdue by more than two windows",
39
+ "budget-80": "spend passed 80% of the declared budget",
40
+ "crash-looping": "a daemon is crash-looping",
41
+ "credential-probe-failed": "a credential probe failed",
42
+ };
43
+
44
+ const SINKS = ["in-app", "os", "webhook"];
45
+
46
+ /** Why an event did not notify — the sentence beside the reason slug. */
47
+ const SUPPRESSED_REASONS = {
48
+ "rule-off": "the rule is off",
49
+ "group-muted": "the harness is in a muted group",
50
+ "quiet-hours": "quiet hours silenced every sink it had",
51
+ "already-delivered": "already notified this session",
52
+ "sink-unavailable": "no sink this manager can deliver on was selected",
53
+ };
54
+
55
+ export async function renderSettings(root) {
56
+ clear(root).appendChild(skeleton(6));
57
+ const [notifRes, readOnlyRes, pluginsRes, groupsRes] = await Promise.allSettled([
58
+ // The shared poll's state — never a second evaluating GET from here.
59
+ notifications.current(),
60
+ api.readOnly(),
61
+ api.plugins(),
62
+ api.groups(),
63
+ ]);
64
+ clear(root);
65
+ root.appendChild(
66
+ el("div", { class: "rollup" }, [
67
+ el("h2", { text: "Settings" }),
68
+ el("span", { class: "muted", text: "manager preferences — never harness state" }),
69
+ ]),
70
+ );
71
+ root.appendChild(readOnlyCard(settled(readOnlyRes)));
72
+ root.appendChild(
73
+ notificationCard(settled(notifRes), settled(groupsRes), () => renderSettings(root)),
74
+ );
75
+ root.appendChild(pluginCard(settled(pluginsRes)));
76
+ }
77
+
78
+ function settled(res) {
79
+ return res.status === "fulfilled" ? res.value : null;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Read-only mode
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function readOnlyCard(state) {
87
+ const enabled = state?.enabled === true;
88
+ const locked = state?.locked === true;
89
+ const card = el("section", { class: "card" }, [
90
+ el("h3", { class: "card-title", text: "Read-only mode" }),
91
+ el("p", { class: "muted", text: String(state?.note ?? "") }),
92
+ el("div", { class: "add-form" }, [
93
+ dot(enabled ? "warn" : "ok", enabled ? "engaged — writes refused" : "off — writes allowed"),
94
+ locked ? el("span", { class: "chip chip-warn", text: "locked at startup" }) : null,
95
+ ]),
96
+ ]);
97
+ const button = el("button", {
98
+ class: enabled ? "btn" : "btn btn-danger",
99
+ type: "button",
100
+ text: enabled ? "Turn read-only off" : "Turn read-only on",
101
+ onClick: async () => {
102
+ button.disabled = true;
103
+ const res = await api.setReadOnly(!enabled);
104
+ button.disabled = false;
105
+ if (res.ok !== true) {
106
+ toast(
107
+ `${String(res.body?.message ?? "refused")} — ${String(res.body?.remedy ?? "")}`,
108
+ "error",
109
+ );
110
+ return;
111
+ }
112
+ window.location.reload();
113
+ },
114
+ });
115
+ if (locked && enabled) {
116
+ button.disabled = true;
117
+ button.title = "this manager was started with the mode locked — restart it without --read-only";
118
+ }
119
+ card.appendChild(button);
120
+ card.appendChild(
121
+ el("p", { class: "muted note" }, [
122
+ el("span", { text: "Exempt while engaged: " }),
123
+ el("code", {
124
+ class: "mono",
125
+ text: (Array.isArray(state?.exempt) ? state.exempt : []).join(", ") || "—",
126
+ }),
127
+ ]),
128
+ );
129
+ return card;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Notification rules
134
+ // ---------------------------------------------------------------------------
135
+
136
+ function notificationCard(state, groupsPayload, reload) {
137
+ const rules = Array.isArray(state?.rules) ? state.rules : [];
138
+ const card = el("section", { class: "card" }, [
139
+ el("h3", { class: "card-title", text: "Notifications" }),
140
+ el("p", {
141
+ class: "muted",
142
+ text: "Only parked approvals notify by default — everything else is visible on a screen you already open, so it waits for you to ask for it.",
143
+ }),
144
+ ]);
145
+ if (rules.length === 0) {
146
+ card.appendChild(emptyState("No rules loaded", "crewhaus hangar serve"));
147
+ return card;
148
+ }
149
+ const groups = Array.isArray(groupsPayload?.groups)
150
+ ? groupsPayload.groups.map((g) => String(g?.name ?? ""))
151
+ : [];
152
+ const draft = {
153
+ rules: rules.map((r) => ({
154
+ kind: String(r?.kind ?? ""),
155
+ enabled: r?.enabled === true,
156
+ sinks: Array.isArray(r?.sinks) ? r.sinks.map(String) : ["in-app"],
157
+ mutedGroups: Array.isArray(r?.mutedGroups) ? r.mutedGroups.map(String) : [],
158
+ })),
159
+ quietHours: {
160
+ enabled: state?.quietHours?.enabled === true,
161
+ startHour: Number(state?.quietHours?.startHour ?? 22),
162
+ endHour: Number(state?.quietHours?.endHour ?? 7),
163
+ utcOffsetMinutes: Number(state?.quietHours?.utcOffsetMinutes ?? 0),
164
+ },
165
+ mutedGroups: Array.isArray(state?.mutedGroups) ? state.mutedGroups.map(String) : [],
166
+ webhookUrl: typeof state?.webhookUrl === "string" ? state.webhookUrl : "",
167
+ };
168
+
169
+ const table = el("table", { class: "table rules-table" });
170
+ table.appendChild(
171
+ el("thead", null, [
172
+ el("tr", null, [
173
+ el("th", { text: "Notify when" }),
174
+ el("th", { text: "On" }),
175
+ ...SINKS.map((s) => el("th", { text: s })),
176
+ ]),
177
+ ]),
178
+ );
179
+ const tbody = el("tbody");
180
+ for (const rule of draft.rules) {
181
+ const row = el("tr", null, [
182
+ el("td", null, [
183
+ el("span", { text: KIND_LABELS[rule.kind] ?? rule.kind }),
184
+ el("div", { class: "muted mono note", text: rule.kind }),
185
+ ]),
186
+ el("td", null, [
187
+ checkbox(
188
+ rule.enabled,
189
+ (on) => {
190
+ rule.enabled = on;
191
+ },
192
+ `enable ${rule.kind}`,
193
+ ),
194
+ ]),
195
+ ...SINKS.map((sink) =>
196
+ el("td", null, [
197
+ checkbox(
198
+ rule.sinks.includes(sink),
199
+ (on) => {
200
+ rule.sinks = on
201
+ ? [...new Set([...rule.sinks, sink])]
202
+ : rule.sinks.filter((s) => s !== sink);
203
+ },
204
+ `${sink} for ${rule.kind}`,
205
+ ),
206
+ ]),
207
+ ),
208
+ ]);
209
+ tbody.appendChild(row);
210
+ }
211
+ table.appendChild(tbody);
212
+ card.appendChild(el("div", { class: "table-scroll" }, [table]));
213
+
214
+ // Quiet hours + muting + webhook.
215
+ const start = numberField(draft.quietHours.startHour, "quiet hours start (0–23)");
216
+ const end = numberField(draft.quietHours.endHour, "quiet hours end (0–23)");
217
+ const offset = numberField(draft.quietHours.utcOffsetMinutes, "UTC offset in minutes");
218
+ const quietOn = checkbox(
219
+ draft.quietHours.enabled,
220
+ (on) => {
221
+ draft.quietHours.enabled = on;
222
+ },
223
+ "quiet hours",
224
+ );
225
+ card.appendChild(
226
+ el("div", { class: "add-form" }, [
227
+ el("label", { class: "field-inline" }, [quietOn, el("span", { text: " Quiet hours" })]),
228
+ el("label", { class: "field-inline" }, [el("span", { text: "from " }), start]),
229
+ el("label", { class: "field-inline" }, [el("span", { text: "to " }), end]),
230
+ el("label", { class: "field-inline" }, [el("span", { text: "UTC offset (min) " }), offset]),
231
+ ]),
232
+ );
233
+ card.appendChild(
234
+ el("p", {
235
+ class: "muted note",
236
+ text: "Quiet hours silence the OS toast and the webhook. The in-app badge stays — waking up to a console that says everything is fine would be worse than a number you can ignore.",
237
+ }),
238
+ );
239
+
240
+ const mutedField = el("input", {
241
+ class: "input mono grow",
242
+ type: "text",
243
+ value: draft.mutedGroups.join(", "),
244
+ placeholder: groups.length > 0 ? groups.join(", ") : "group-a, group-b",
245
+ "aria-label": "muted groups",
246
+ });
247
+ const webhookField = el("input", {
248
+ class: "input mono grow",
249
+ type: "url",
250
+ value: draft.webhookUrl,
251
+ placeholder: "https://hooks.example.com/hangar",
252
+ "aria-label": "webhook URL",
253
+ });
254
+ card.appendChild(
255
+ el("div", { class: "add-form" }, [
256
+ el("label", { class: "field-inline" }, [el("span", { text: "Mute groups " }), mutedField]),
257
+ el("label", { class: "field-inline" }, [el("span", { text: "Webhook " }), webhookField]),
258
+ ]),
259
+ );
260
+ card.appendChild(
261
+ el("p", {
262
+ class: "muted note",
263
+ text: "A webhook URL must not embed credentials — the manager refuses one that does rather than storing it and masking it later.",
264
+ }),
265
+ );
266
+ card.appendChild(
267
+ el("div", { class: "add-form" }, [
268
+ el("button", {
269
+ class: "btn btn-primary",
270
+ type: "button",
271
+ text: "Save rules",
272
+ onClick: async (event) => {
273
+ const button = event.currentTarget;
274
+ button.disabled = true;
275
+ try {
276
+ const updated = await api.setNotifications({
277
+ rules: draft.rules,
278
+ quietHours: {
279
+ enabled: draft.quietHours.enabled,
280
+ startHour: Number(start.value),
281
+ endHour: Number(end.value),
282
+ utcOffsetMinutes: Number(offset.value),
283
+ },
284
+ mutedGroups: splitList(mutedField.value),
285
+ webhookUrl: webhookField.value.trim() === "" ? null : webhookField.value.trim(),
286
+ });
287
+ // The PUT answers with the same evaluated view the GET does —
288
+ // deliveries included. Fold it into the shared poll so nothing it
289
+ // just consumed goes unseen, and so the badge follows the save.
290
+ notifications.accept(updated);
291
+ toast("Notification rules saved", "info");
292
+ } catch (err) {
293
+ toast(err instanceof Error ? err.message : String(err), "error");
294
+ }
295
+ button.disabled = false;
296
+ },
297
+ }),
298
+ el("button", {
299
+ class: "btn btn-ghost",
300
+ type: "button",
301
+ text: "Clear the badge",
302
+ onClick: async () => {
303
+ try {
304
+ await api.clearNotifications();
305
+ await notifications.refresh();
306
+ toast("Badge cleared", "info");
307
+ reload?.();
308
+ } catch (err) {
309
+ toast(err instanceof Error ? err.message : String(err), "error");
310
+ }
311
+ },
312
+ }),
313
+ ]),
314
+ );
315
+ card.appendChild(pendingList(state));
316
+ const why = suppressedList(state);
317
+ if (why !== null) card.appendChild(why);
318
+ return card;
319
+ }
320
+
321
+ /**
322
+ * What the badge is COUNTING. A number an operator cannot open is a number
323
+ * they learn to ignore, and the in-app queue is the only record of a
324
+ * delivery there is — the dedupe set means the server will not report it a
325
+ * second time.
326
+ */
327
+ function pendingList(state) {
328
+ const inApp = Array.isArray(state?.inApp) ? state.inApp : [];
329
+ const box = el("div", { class: "notif-queue" }, [
330
+ el("h4", { class: "sheet-section", text: "Waiting for you" }),
331
+ ]);
332
+ if (inApp.length === 0) {
333
+ box.appendChild(el("p", { class: "muted note", text: "Nothing pending." }));
334
+ return box;
335
+ }
336
+ const now = Date.now();
337
+ box.appendChild(
338
+ el(
339
+ "ul",
340
+ { class: "check-list" },
341
+ inApp.map((d) => {
342
+ const id = String(d?.harnessId ?? "");
343
+ const label = String(d?.label ?? d?.kind ?? "");
344
+ return el("li", null, [
345
+ dot("warn", label),
346
+ el("span", { class: "muted", text: ` ${fmtRelativeTime(String(d?.at ?? ""), now)}` }),
347
+ id === ""
348
+ ? null
349
+ : el("a", { class: "chip", href: hrefHarness(id, "overview"), text: "open →" }),
350
+ ]);
351
+ }),
352
+ ),
353
+ );
354
+ return box;
355
+ }
356
+
357
+ /**
358
+ * …and what did NOT notify, with the reason. A silent rule has to be
359
+ * explainable from the screen, or the operator's only model of the feature
360
+ * is "it sometimes tells me things".
361
+ */
362
+ function suppressedList(state) {
363
+ const suppressed = Array.isArray(state?.suppressed) ? state.suppressed : [];
364
+ if (suppressed.length === 0) return null;
365
+ const counts = new Map();
366
+ for (const s of suppressed) {
367
+ const reason = String(s?.reason ?? "unknown");
368
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
369
+ }
370
+ return el("div", { class: "notif-suppressed" }, [
371
+ el("h4", { class: "sheet-section", text: "Seen, but not notified" }),
372
+ el(
373
+ "ul",
374
+ { class: "check-list" },
375
+ [...counts.entries()].map(([reason, n]) =>
376
+ el("li", null, [
377
+ dot("off", `${n} × ${reason}`),
378
+ el("span", { class: "muted", text: ` — ${SUPPRESSED_REASONS[reason] ?? reason}` }),
379
+ ]),
380
+ ),
381
+ ),
382
+ ]);
383
+ }
384
+
385
+ function checkbox(checked, onChange, label) {
386
+ const node = el("input", { type: "checkbox", "aria-label": label });
387
+ node.checked = checked === true;
388
+ node.addEventListener("change", () => onChange(node.checked));
389
+ return node;
390
+ }
391
+
392
+ function numberField(value, label) {
393
+ const node = el("input", {
394
+ class: "input num-input",
395
+ type: "number",
396
+ value: String(value),
397
+ "aria-label": label,
398
+ });
399
+ return node;
400
+ }
401
+
402
+ /** "a, b ,, c" → ["a","b","c"]. Exported for the unit tests. */
403
+ export function splitList(text) {
404
+ return String(text ?? "")
405
+ .split(",")
406
+ .map((s) => s.trim())
407
+ .filter((s) => s !== "");
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Plugins
412
+ // ---------------------------------------------------------------------------
413
+
414
+ function pluginCard(inventory) {
415
+ const plugins = Array.isArray(inventory?.plugins) ? inventory.plugins : [];
416
+ const card = el("section", { class: "card" }, [
417
+ el("h3", { class: "card-title", text: "Plugins" }),
418
+ el("p", { class: "muted" }, [
419
+ el("span", { text: "This build wires " }),
420
+ el("code", { class: "mono", text: (inventory?.wired ?? []).join(", ") || "nothing" }),
421
+ el("span", {
422
+ text: ". A plugin's pane runs in a sandboxed frame with an opaque origin and a policy built from its own net allow-list; a plugin that may not read a harness sees neither its panes nor its trace events.",
423
+ }),
424
+ ]),
425
+ ]);
426
+ const deferred =
427
+ inventory?.deferred && typeof inventory.deferred === "object"
428
+ ? Object.entries(inventory.deferred)
429
+ : [];
430
+ if (deferred.length > 0) {
431
+ card.appendChild(
432
+ el("ul", { class: "check-list" }, [
433
+ el("li", { class: "muted", text: "Declared but NOT wired (deferred):" }),
434
+ ...deferred.map(([point, reason]) =>
435
+ el("li", null, [
436
+ dot("off", point),
437
+ el("span", { class: "muted", text: ` — ${String(reason)}` }),
438
+ ]),
439
+ ),
440
+ ]),
441
+ );
442
+ }
443
+ if (plugins.length === 0) {
444
+ card.appendChild(emptyState("No plugins installed", "crewhaus plugins install <name>"));
445
+ return card;
446
+ }
447
+ card.appendChild(
448
+ el(
449
+ "ul",
450
+ { class: "check-list" },
451
+ plugins.map((plugin) => pluginRow(plugin)),
452
+ ),
453
+ );
454
+ return card;
455
+ }
456
+
457
+ function pluginRow(plugin) {
458
+ const problems = Array.isArray(plugin?.problems) ? plugin.problems : [];
459
+ const points = Array.isArray(plugin?.extensionPoints) ? plugin.extensionPoints : [];
460
+ const panes = Array.isArray(plugin?.panes) ? plugin.panes : [];
461
+ return el("li", { class: "plugin-row" }, [
462
+ el("div", null, [
463
+ el("strong", { text: String(plugin?.name ?? "") }),
464
+ el("span", { class: "muted mono", text: ` ${String(plugin?.version ?? "")}` }),
465
+ problems.length > 0 ? dot("bad", problems.join("; ")) : null,
466
+ ]),
467
+ plugin?.description ? el("div", { class: "muted", text: String(plugin.description) }) : null,
468
+ el(
469
+ "div",
470
+ { class: "safety-strip" },
471
+ points
472
+ .filter((p) => p.declared === true)
473
+ .map((p) =>
474
+ el("span", {
475
+ class: `chip${p.wired === true ? "" : " chip-warn"}`,
476
+ title: p.wired === true ? "wired" : String(p.reason ?? "not wired"),
477
+ text: `${p.point}: ${p.wired === true ? "wired" : "deferred"}`,
478
+ }),
479
+ ),
480
+ ),
481
+ panes.length > 0
482
+ ? el("div", { class: "muted", text: `panes: ${panes.map((p) => p.id).join(", ")}` })
483
+ : null,
484
+ el("div", { class: "muted mono note" }, [
485
+ el("span", { text: `fs: ${(plugin?.permissions?.fs ?? []).join(" ") || "none"}` }),
486
+ el("span", { text: ` · net: ${(plugin?.permissions?.net ?? []).join(" ") || "none"}` }),
487
+ ]),
488
+ ]);
489
+ }