@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,253 @@
1
+ /**
2
+ * The shared M2 action affordances: the CLI twin display, buttons that are
3
+ * disabled WITH THEIR REASON, the control.v1 wake/drain control, and the
4
+ * preflight-refusal modal.
5
+ *
6
+ * Two invariants live here rather than in each screen:
7
+ *
8
+ * - **Every action shows the command it runs.** The CLI and the console
9
+ * drive the same harness-local state tree, so a twin is not decoration:
10
+ * it is how an operator verifies what a click will do, and how they take
11
+ * the same action from a terminal when the console is not running.
12
+ * - **A refusal is rendered, never toasted away.** A control.v1 envelope
13
+ * with `expected: true` (no control port, lane not armed, draining) is a
14
+ * FACT about this bundle: the control renders disabled with the server's
15
+ * sentence. Only `tick_in_flight` retries.
16
+ */
17
+
18
+ import { copyBtn, dot, el, toast } from "../dom.js";
19
+ import { NO_TWIN_NOTES, cliTwinFor, controlOutcome } from "../supervision.js";
20
+
21
+ /**
22
+ * The CLI twin line: the exact command, monospaced, with a copy button.
23
+ * `note` renders instead when there is no CLI verb yet — an honest absence
24
+ * beats an invented command.
25
+ */
26
+ export function cliTwin(command, note = null) {
27
+ if (typeof command !== "string" || command === "") {
28
+ return note === null
29
+ ? null
30
+ : el("div", { class: "twin twin-none" }, [
31
+ el("span", { class: "twin-label", text: "CLI twin" }),
32
+ el("span", { class: "muted", text: note }),
33
+ ]);
34
+ }
35
+ return el("div", { class: "twin" }, [
36
+ el("span", { class: "twin-label", text: "CLI twin" }),
37
+ el("code", { class: "twin-cmd", text: command }),
38
+ copyBtn(command, "copy"),
39
+ ]);
40
+ }
41
+
42
+ /** The twin for a named action (see `cliTwinFor`), or its honest absence. */
43
+ export function actionTwin(action, args) {
44
+ return cliTwin(cliTwinFor(action, args), NO_TWIN_NOTES[action] ?? null);
45
+ }
46
+
47
+ /**
48
+ * A button that either works or says why it does not. `gate` is
49
+ * `{ enabled, reason }` — the shape `procActions` and `pokeReason` both
50
+ * produce — and a disabled button keeps its reason in the title AND in a
51
+ * sibling line, because a tooltip is invisible to a keyboard user.
52
+ */
53
+ export function gatedBtn(label, gate, onClick, cls = "btn") {
54
+ const g = gate && typeof gate === "object" ? gate : { enabled: true, reason: null };
55
+ const btn = el("button", {
56
+ class: cls,
57
+ type: "button",
58
+ text: label,
59
+ title: g.enabled ? label : (g.reason ?? "unavailable"),
60
+ });
61
+ if (!g.enabled) btn.disabled = true;
62
+ else btn.addEventListener("click", onClick);
63
+ return btn;
64
+ }
65
+
66
+ /** A gated button plus the sentence explaining a disabled state. */
67
+ export function gatedAction(label, gate, onClick, cls = "btn") {
68
+ const g = gate && typeof gate === "object" ? gate : { enabled: true, reason: null };
69
+ return el("div", { class: "gated" }, [
70
+ gatedBtn(label, g, onClick, cls),
71
+ g.enabled || !g.reason ? null : el("span", { class: "muted gated-why", text: g.reason }),
72
+ ]);
73
+ }
74
+
75
+ /**
76
+ * Run one write with a busy button; failures surface (never silently), and
77
+ * the caller's `onOk` reloads. Returns the promise so callers can await.
78
+ */
79
+ export async function runAction(btn, label, fn, onOk) {
80
+ const original = btn.textContent;
81
+ btn.disabled = true;
82
+ btn.textContent = `${label}…`;
83
+ try {
84
+ const result = await fn();
85
+ btn.textContent = original;
86
+ btn.disabled = false;
87
+ if (onOk) onOk(result);
88
+ return result;
89
+ } catch (err) {
90
+ btn.textContent = original;
91
+ btn.disabled = false;
92
+ toast(`${label} failed: ${err instanceof Error ? err.message : String(err)}`);
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * A control.v1 control (Wake / Drain). `gate` decides whether it is offered
99
+ * at all (a lane the spec never declared has nothing to poke); the ENVELOPE
100
+ * decides what happens after a click:
101
+ *
102
+ * - `expected` refusal ⇒ the button disables itself and shows the reason;
103
+ * - `tick_in_flight` ⇒ retryable: a spinner, then the same button back;
104
+ * - anything else ⇒ a real fault, surfaced as an error.
105
+ */
106
+ export function controlBtn(label, gate, call, twinArgs, onSettled) {
107
+ const state = el("div", { class: "control-state" });
108
+ const btn = gatedBtn(label, gate, async () => {
109
+ btn.disabled = true;
110
+ const spinner = el("span", { class: "muted", text: "…" });
111
+ state.replaceChildren(spinner);
112
+ let envelope;
113
+ try {
114
+ envelope = await call();
115
+ } catch (err) {
116
+ state.replaceChildren(
117
+ el("span", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
118
+ );
119
+ btn.disabled = false;
120
+ return;
121
+ }
122
+ const outcome = controlOutcome(envelope);
123
+ if (outcome.ok) {
124
+ state.replaceChildren(dot("ok", "accepted"));
125
+ btn.disabled = false;
126
+ } else if (outcome.retryable) {
127
+ // A tick is already running — the same call succeeds shortly.
128
+ state.replaceChildren(dot("warn", `${outcome.reason} — retry in a moment`));
129
+ btn.disabled = false;
130
+ } else {
131
+ // `expected` ⇒ this control is disabled-with-reason from here on.
132
+ state.replaceChildren(dot(outcome.dot, outcome.reason));
133
+ btn.disabled = outcome.disabled;
134
+ if (!outcome.expected) toast(`${label} failed: ${outcome.reason}`);
135
+ }
136
+ if (onSettled) onSettled(outcome);
137
+ });
138
+ const wrap = el("div", { class: "control-action" }, [
139
+ el("div", { class: "control-row" }, [btn, state]),
140
+ gate && gate.enabled === false && gate.reason
141
+ ? el("span", { class: "muted gated-why", text: gate.reason })
142
+ : null,
143
+ twinArgs ? actionTwin(twinArgs.action, twinArgs) : null,
144
+ ]);
145
+ return wrap;
146
+ }
147
+
148
+ /**
149
+ * The preflight-refusal modal. Every refused item lists its remediation, and
150
+ * "Start anyway" appears ONLY when every item is acknowledgeable — a missing
151
+ * channel secret is unforceable because the compiled daemon's own boot gate
152
+ * exits 2 on exactly that set, so forcing it would spawn a process
153
+ * guaranteed to die.
154
+ */
155
+ export function refusalModal(model, handlers = {}) {
156
+ const backdrop = el("div", { class: "modal-backdrop", role: "presentation" });
157
+ const close = () => backdrop.remove();
158
+ const body = el("div", { class: "modal-body" });
159
+
160
+ if (model.items.length > 0) {
161
+ body.appendChild(
162
+ el(
163
+ "ul",
164
+ { class: "refusal-list" },
165
+ model.items.map((item) =>
166
+ el("li", { class: "refusal-item" }, [
167
+ el("div", { class: "refusal-head" }, [
168
+ dot(item.acknowledgeable ? "warn" : "bad", item.level),
169
+ el("span", { class: "mono muted", text: item.area }),
170
+ el("span", { text: item.message }),
171
+ ]),
172
+ item.remediation
173
+ ? el("div", { class: "reason muted" }, [
174
+ el("strong", { text: "Fix: " }),
175
+ item.remediation,
176
+ ])
177
+ : null,
178
+ item.acknowledgeable
179
+ ? null
180
+ : el("div", { class: "reason" }, [
181
+ el("span", {
182
+ class: "chip chip-warn",
183
+ text: "unforceable — the daemon's own boot gate exits 2 on this",
184
+ }),
185
+ ]),
186
+ ]),
187
+ ),
188
+ ),
189
+ );
190
+ } else {
191
+ body.appendChild(el("p", { text: model.message }));
192
+ }
193
+ if (model.forceHint) body.appendChild(el("p", { class: "muted", text: model.forceHint }));
194
+
195
+ const actions = el("div", { class: "modal-actions" });
196
+ if (model.canForce && handlers.onForce) {
197
+ const forceBtn = el("button", {
198
+ class: "btn btn-danger",
199
+ type: "button",
200
+ text: "Start anyway",
201
+ });
202
+ forceBtn.addEventListener("click", () => {
203
+ close();
204
+ handlers.onForce(model.acknowledge);
205
+ });
206
+ actions.appendChild(forceBtn);
207
+ }
208
+ if (model.action && model.action.jobKind !== null && handlers.onRemedy) {
209
+ // A remedy is a BUTTON, never a toast: "no compiled bundle" has an
210
+ // obvious next step and the console can run it.
211
+ const remedyBtn = el("button", {
212
+ class: "btn btn-primary",
213
+ type: "button",
214
+ text: model.action.label,
215
+ });
216
+ remedyBtn.addEventListener("click", () => {
217
+ close();
218
+ handlers.onRemedy(model.action.jobKind);
219
+ });
220
+ actions.appendChild(remedyBtn);
221
+ } else if (model.action) {
222
+ actions.appendChild(el("span", { class: "muted", text: model.action.hint }));
223
+ }
224
+ actions.appendChild(
225
+ el("button", { class: "btn btn-ghost", type: "button", text: "Close", onClick: close }),
226
+ );
227
+
228
+ backdrop.appendChild(
229
+ el("div", { class: "modal card", role: "dialog", "aria-modal": "true" }, [
230
+ el("h3", { class: "card-title" }, [
231
+ el("span", { text: model.title }),
232
+ model.canForce ? null : el("span", { class: "chip chip-warn", text: "not forceable" }),
233
+ ]),
234
+ model.items.length > 0 ? el("p", { class: "muted", text: model.message }) : null,
235
+ body,
236
+ model.canForce && handlers.onForce
237
+ ? cliTwin(
238
+ cliTwinFor("start", {
239
+ dir: handlers.dir,
240
+ force: true,
241
+ acknowledge: model.acknowledge,
242
+ }),
243
+ )
244
+ : null,
245
+ actions,
246
+ ]),
247
+ );
248
+ backdrop.addEventListener("click", (e) => {
249
+ if (e.target === backdrop) close();
250
+ });
251
+ document.body.appendChild(backdrop);
252
+ return backdrop;
253
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Costs tab — per-model spend table plus the 7-day bar mini-chart (inline
3
+ * SVG, no chart lib). The route returns `{ id, costs }` where `costs` holds
4
+ * integer USD-micros figures (`byModel`, zero-filled `days`, rolling
5
+ * `spend7dUsdMicros`, `truncatedFiles`); dollars are derived client-side in
6
+ * one place (`usdFromMicros`). Capped reads are flagged, never hidden.
7
+ */
8
+
9
+ import { api } from "../api.js";
10
+ import { clear, el, emptyState, skeleton, svgEl } from "../dom.js";
11
+ import { barRects, fmtCount, fmtUsd, usdFromMicros } from "../util.js";
12
+
13
+ export async function renderCosts(root, ctx) {
14
+ clear(root).appendChild(skeleton(6));
15
+ const data = await api.costs(ctx.id);
16
+ clear(root);
17
+ const costs = data?.costs && typeof data.costs === "object" ? data.costs : null;
18
+ const models = Array.isArray(costs?.byModel) ? costs.byModel : [];
19
+ const days = Array.isArray(costs?.days) ? costs.days : [];
20
+ if (costs === null || (models.length === 0 && costs.calls === 0)) {
21
+ root.appendChild(
22
+ emptyState("No cost data yet", "crewhaus run (cost tracking is on by default)"),
23
+ );
24
+ return;
25
+ }
26
+
27
+ if (days.length > 0) {
28
+ const shown = days.slice(-7);
29
+ const values = shown.map((d) => usdFromMicros(d.usdMicros) ?? 0);
30
+ const rects = barRects(values, 420, 64);
31
+ root.appendChild(
32
+ el("div", { class: "card" }, [
33
+ el("h3", { class: "card-title" }, [
34
+ el("span", { text: "Last 7 days" }),
35
+ el("span", {
36
+ class: "muted card-sub",
37
+ text: fmtUsd(usdFromMicros(costs.spend7dUsdMicros)),
38
+ }),
39
+ typeof costs.truncatedFiles === "number" && costs.truncatedFiles > 0
40
+ ? el("span", {
41
+ class: "chip chip-warn",
42
+ title: "some session logs hit the read cap — totals are floors, not lies",
43
+ text: `${costs.truncatedFiles} capped file${costs.truncatedFiles === 1 ? "" : "s"}`,
44
+ })
45
+ : null,
46
+ ]),
47
+ svgEl(
48
+ "svg",
49
+ {
50
+ class: "bars bars-lg",
51
+ viewBox: "0 0 420 64",
52
+ role: "img",
53
+ "aria-label": "daily spend",
54
+ },
55
+ rects.map((r) => svgEl("rect", { x: r.x, y: r.y, width: r.w, height: r.h, rx: "1.5" })),
56
+ ),
57
+ el(
58
+ "div",
59
+ { class: "bar-labels" },
60
+ shown.map((d) => el("span", { class: "muted", text: String(d.day ?? "").slice(5) })),
61
+ ),
62
+ ]),
63
+ );
64
+ }
65
+
66
+ if (models.length > 0) {
67
+ const tbody = el("tbody");
68
+ for (const m of models) {
69
+ tbody.appendChild(
70
+ el("tr", null, [
71
+ el("td", { class: "mono", text: String(m.modelId ?? "?") }),
72
+ el("td", { text: String(m.provider ?? "—") }),
73
+ el("td", { class: "num", text: fmtCount(numOr(m.calls)) }),
74
+ el("td", { class: "num", text: fmtCount(numOr(m.inputTokens)) }),
75
+ el("td", { class: "num", text: fmtCount(numOr(m.outputTokens)) }),
76
+ el("td", { class: "num", text: fmtUsd(usdFromMicros(m.usdMicros)) }),
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
+ ["Model", "Provider", "Calls", "In tokens", "Out tokens", "Cost"].map((h) =>
90
+ el("th", { text: h }),
91
+ ),
92
+ ),
93
+ ),
94
+ tbody,
95
+ ]),
96
+ ]),
97
+ );
98
+ }
99
+ }
100
+
101
+ function numOr(v) {
102
+ return typeof v === "number" ? v : null;
103
+ }