@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,21 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="referrer" content="no-referrer" />
7
+ <title>Hangar — CrewHaus</title>
8
+ <link rel="stylesheet" href="/assets/hangar.css" />
9
+ </head>
10
+ <body>
11
+ <div id="app">
12
+ <noscript>Hangar needs JavaScript enabled.</noscript>
13
+ <div class="skeleton boot" aria-label="loading" role="status">
14
+ <div class="skel-line"></div>
15
+ <div class="skel-line"></div>
16
+ <div class="skel-line"></div>
17
+ </div>
18
+ </div>
19
+ <script type="module" src="/assets/js/app.js"></script>
20
+ </body>
21
+ </html>
@@ -0,0 +1,368 @@
1
+ /**
2
+ * API client. Every request is built from the `routes.js` contract map
3
+ * (the same map the hangar server's contract test drives), attaches the
4
+ * bearer token, and enforces two read-path invariants:
5
+ *
6
+ * - 404 → `null`, never an error ("absence is not an error" — the caller
7
+ * renders a "nothing yet" empty state);
8
+ * - 401 → the registered unauthorized handler fires (the app swaps in the
9
+ * token screen) and the request rejects with `ApiError(401)`.
10
+ *
11
+ * The token arrives via the `#t=<token>` URL fragment (fragments never
12
+ * reach server logs) and lives in sessionStorage only — never in a cookie
13
+ * (no cookies ⇒ no CSRF surface) and never in a query string.
14
+ */
15
+
16
+ import { ROUTES, buildPath, m3RouteKeys } from "./routes.js";
17
+ import { createSseParser } from "./supervision.js";
18
+
19
+ const TOKEN_KEY = "hangar.token";
20
+
21
+ let unauthorizedHandler = null;
22
+
23
+ /** Register the single 401 handler (the app's token screen). */
24
+ export function onUnauthorized(fn) {
25
+ unauthorizedHandler = fn;
26
+ }
27
+
28
+ /** Current bearer token, or null. */
29
+ export function getToken() {
30
+ try {
31
+ return sessionStorage.getItem(TOKEN_KEY);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Store (or clear, with null) the bearer token for this tab session. */
38
+ export function setToken(token) {
39
+ try {
40
+ if (token === null || token === "") sessionStorage.removeItem(TOKEN_KEY);
41
+ else sessionStorage.setItem(TOKEN_KEY, token);
42
+ } catch {
43
+ // sessionStorage unavailable — requests proceed unauthenticated and the
44
+ // server's 401 keeps routing the user to the token screen.
45
+ }
46
+ }
47
+
48
+ /** Error for non-404 failures; `status` is the HTTP status (0 = network). */
49
+ export class ApiError extends Error {
50
+ constructor(status, message) {
51
+ super(message);
52
+ this.name = "ApiError";
53
+ this.status = status;
54
+ }
55
+ }
56
+
57
+ function authHeaders(accept) {
58
+ const headers = { accept };
59
+ const token = getToken();
60
+ if (token) headers.authorization = `Bearer ${token}`;
61
+ return headers;
62
+ }
63
+
64
+ /** One request, reported as `{ status, body }` — no throwing on a non-2xx.
65
+ * 401 still fires the unauthorized handler (the token screen owns the view). */
66
+ async function rawRequest(method, path, body) {
67
+ const headers = authHeaders("application/json");
68
+ const init = { method, headers };
69
+ if (body !== undefined) {
70
+ headers["content-type"] = "application/json";
71
+ init.body = JSON.stringify(body);
72
+ }
73
+ let res;
74
+ try {
75
+ res = await fetch(path, init);
76
+ } catch (err) {
77
+ throw new ApiError(0, `network error: ${err instanceof Error ? err.message : String(err)}`);
78
+ }
79
+ if (res.status === 401) {
80
+ if (unauthorizedHandler) unauthorizedHandler();
81
+ throw new ApiError(401, "unauthorized");
82
+ }
83
+ if (res.status === 404 || res.status === 204) return { status: res.status, body: null };
84
+ let parsed = null;
85
+ let text = "";
86
+ try {
87
+ text = await res.text();
88
+ parsed = text === "" ? null : JSON.parse(text);
89
+ } catch {
90
+ parsed = null; // an unparseable body leaves `text` as the message
91
+ }
92
+ return { status: res.status, body: parsed, text };
93
+ }
94
+
95
+ async function request(method, path, body) {
96
+ const { status, body: parsed, text } = await rawRequest(method, path, body);
97
+ if (status === 404 || status === 204) return null;
98
+ if (status < 200 || status >= 300) {
99
+ throw new ApiError(status, text !== undefined && text !== "" ? text : `HTTP ${status}`);
100
+ }
101
+ return parsed;
102
+ }
103
+
104
+ /** Issue one request from a `ROUTES` entry (+ params + optional body/query). */
105
+ function call(route, params, body, query = "") {
106
+ return request(route.method, `${buildPath(route.path, params)}${query}`, body);
107
+ }
108
+
109
+ /**
110
+ * Issue one request whose REFUSAL is a payload, not an error: `POST
111
+ * /proc/{start,restart}` answers 409 with the typed preflight refusal the
112
+ * console renders as a modal, and `POST /proc/{stop,drain}` answers 409
113
+ * `not-adopted` when a daemon is running that this manager never adopted.
114
+ * Swallowing either into an ApiError would throw away the very thing the
115
+ * screen exists to show. Returns `{ ok, status, body }`.
116
+ */
117
+ async function callTyped(route, params, body) {
118
+ const {
119
+ status,
120
+ body: parsed,
121
+ text,
122
+ } = await rawRequest(route.method, buildPath(route.path, params), body);
123
+ return {
124
+ ok: status >= 200 && status < 300,
125
+ status,
126
+ body: parsed ?? (text !== undefined && text !== "" ? { message: text } : {}),
127
+ };
128
+ }
129
+
130
+ /** Body for a start/restart: `{}` unless the operator waved findings through. */
131
+ function startBody(opts) {
132
+ const o = opts && typeof opts === "object" ? opts : {};
133
+ const acknowledge = Array.isArray(o.acknowledge) ? o.acknowledge.map(String) : null;
134
+ return {
135
+ ...(o.force === true ? { force: true } : {}),
136
+ ...(acknowledge !== null && acknowledge.length > 0 ? { acknowledge } : {}),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Open the run's SSE feed. Hand-rolled over `fetch` rather than
142
+ * `EventSource`, which cannot carry the bearer header (its only credential
143
+ * channel is cookies, and this app has none by design).
144
+ *
145
+ * The frame grammar is the server's: `replay` first, then live
146
+ * `state`/`output`/`exit`, and ALWAYS `done` last — so a closed run replays
147
+ * and terminates with no live-vs-dead branch here. A dropped connection
148
+ * emits a synthetic `{ event: "closed" }` so the view can tell the two apart.
149
+ */
150
+ export function streamRunEvents(id, runId, onFrame) {
151
+ const controller = new AbortController();
152
+ const path = buildPath(ROUTES.runEvents.path, { id, runId });
153
+ const emit = (event, data) => {
154
+ if (!controller.signal.aborted) onFrame({ event, data });
155
+ };
156
+ (async () => {
157
+ let res;
158
+ try {
159
+ res = await fetch(path, {
160
+ headers: authHeaders("text/event-stream"),
161
+ signal: controller.signal,
162
+ });
163
+ } catch (err) {
164
+ if (!controller.signal.aborted) {
165
+ emit("error", { message: err instanceof Error ? err.message : String(err) });
166
+ }
167
+ return;
168
+ }
169
+ if (res.status === 401) {
170
+ if (unauthorizedHandler) unauthorizedHandler();
171
+ return;
172
+ }
173
+ if (!res.ok || res.body === null || res.body === undefined) {
174
+ emit("error", { message: res.status === 404 ? "no such run" : `HTTP ${res.status}` });
175
+ return;
176
+ }
177
+ const reader = res.body.getReader();
178
+ const decoder = new TextDecoder();
179
+ const parser = createSseParser();
180
+ for (;;) {
181
+ let chunk;
182
+ try {
183
+ chunk = await reader.read();
184
+ } catch {
185
+ break; // aborted or dropped — the synthetic `closed` frame says so
186
+ }
187
+ if (chunk.done) break;
188
+ for (const frame of parser.push(decoder.decode(chunk.value, { stream: true }))) {
189
+ emit(frame.event, frame.data);
190
+ }
191
+ }
192
+ emit("closed", null);
193
+ })();
194
+ return {
195
+ close: () => {
196
+ controller.abort();
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * One M3 request. Uniform signature — `(params, body, query)` — and a
203
+ * uniform ANSWER: `{ ok, status, body }`, never a throw for a non-2xx.
204
+ *
205
+ * That is deliberate for this surface. M3 refusals are the interesting part:
206
+ * a `409` carrying the human-owned spec diff, a `409 stale_article_version`
207
+ * with the version that moved under you, a `501 not implemented (M3)` while
208
+ * a handler is still a stub. Turning any of those into an `ApiError` throws
209
+ * away the exact payload the screen exists to render. `401` still routes to
210
+ * the token screen (that is a session fact, not a route answer).
211
+ */
212
+ async function callM3(route, params, body, query) {
213
+ const path = `${buildPath(route.path, params ?? {})}${query ?? ""}`;
214
+ const { status, body: parsed, text } = await rawRequest(route.method, path, body);
215
+ return {
216
+ ok: status >= 200 && status < 300,
217
+ status,
218
+ body: parsed ?? (text !== undefined && text !== "" ? { message: text } : {}),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * The M3 wrappers, GENERATED from the route map rather than hand-written.
224
+ *
225
+ * There are ~180 of them across eleven groups; typing each one out would be
226
+ * 180 chances to mistype a path that the contract test would then have to
227
+ * catch. Generating them makes the map the only place a path exists on this
228
+ * side, which is the same reason `routes.js` exists at all. Each wrapper
229
+ * takes `(params, body, query)`; writes default to an empty body so a
230
+ * no-argument call is still a well-formed request.
231
+ */
232
+ const m3Api = {};
233
+ for (const key of m3RouteKeys()) {
234
+ const route = ROUTES[key];
235
+ const needsBody = route.method === "POST" || route.method === "PUT";
236
+ m3Api[key] = (params, body, query) =>
237
+ callM3(route, params, needsBody ? (body ?? {}) : undefined, query);
238
+ }
239
+
240
+ /**
241
+ * Every server route the console talks to — thin named wrappers over the
242
+ * `ROUTES` contract map. M1's writes were registry CRUD only (manager
243
+ * state); M2 adds the driving verbs: process control, the control.v1 proxy,
244
+ * the two inboxes' decisions, the job queue, and the two action faces
245
+ * (session pin, eval baseline); M3's detail surface is spread in from the
246
+ * generated wrappers above (hand-written names below win a collision, so a
247
+ * route that later earns a friendlier signature can simply be written out).
248
+ */
249
+ export const api = {
250
+ ...m3Api,
251
+ // fleet feed + registry CRUD
252
+ harnesses: (hydrate = false) =>
253
+ call(ROUTES.harnesses, {}, undefined, hydrate ? "?hydrate=1" : ""),
254
+ addHarness: (dir) => call(ROUTES.addHarness, {}, { dir }),
255
+ scan: () => call(ROUTES.scan, {}, {}),
256
+ groups: () => call(ROUTES.groups, {}),
257
+ addGroup: (name) => call(ROUTES.addGroup, {}, { name }),
258
+ addScanRoot: (dir) => call(ROUTES.addScanRoot, {}, { dir }),
259
+ setGroups: (id, groups) => call(ROUTES.setGroups, { id }, { groups }),
260
+ setTags: (id, tags) => call(ROUTES.setTags, { id }, { tags }),
261
+ setPin: (id, pinned) => call(ROUTES.setPin, { id }, { pinned }),
262
+ setNotes: (id, notes) => call(ROUTES.setNotes, { id }, { notes }),
263
+ relocateHarness: (id, newDir) => call(ROUTES.relocate, { id }, { newDir }),
264
+ removeHarness: (id) => call(ROUTES.removeHarness, { id }),
265
+
266
+ // per-harness reads
267
+ harness: (id) => call(ROUTES.harness, { id }),
268
+ spec: (id) => call(ROUTES.spec, { id }),
269
+ preflight: (id) => call(ROUTES.preflight, { id }),
270
+ sessions: (id) => call(ROUTES.sessions, { id }),
271
+ session: (id, sess) => call(ROUTES.session, { id, sess }),
272
+ evals: (id) => call(ROUTES.evals, { id }),
273
+ evalRun: (id, runId) => call(ROUTES.evalRun, { id, runId }),
274
+ evalSample: (id, runId, sampleId) => call(ROUTES.evalSample, { id, runId, sampleId }),
275
+ memory: (id, area) => call(ROUTES.memory, { id, area }),
276
+ wikiArticle: (id, slug) => call(ROUTES.wikiArticle, { id, slug }),
277
+ costs: (id) => call(ROUTES.costs, { id }),
278
+
279
+ // ---- M2: the process layer ------------------------------------------
280
+ proc: (id) => call(ROUTES.proc, { id }),
281
+ // start/restart answer 409 with a typed refusal — surfaced, not thrown.
282
+ procStart: (id, opts) => callTyped(ROUTES.procStart, { id }, startBody(opts)),
283
+ procRestart: (id, opts) => callTyped(ROUTES.procRestart, { id }, startBody(opts)),
284
+ // …and so do stop/drain: a 409 `not-adopted` says a daemon IS running
285
+ // that this manager never adopted, so nothing was signalled. Thrown away
286
+ // as an ApiError it reads as a transport fault; kept as a body the console
287
+ // can say what actually happened.
288
+ procStop: (id) => callTyped(ROUTES.procStop, { id }, {}),
289
+ procDrain: (id) => callTyped(ROUTES.procDrain, { id }, {}),
290
+
291
+ // run history + one run's detail (the live feed is `streamRunEvents`)
292
+ runs: (id) => call(ROUTES.runs, { id }),
293
+ run: (id, runId) => call(ROUTES.run, { id, runId }),
294
+
295
+ // ---- M2: crewhaus.control.v1 (always a 200 envelope) ------------------
296
+ controlStatus: (id) => call(ROUTES.controlStatus, { id }),
297
+ controlWake: (id, lane, reason) =>
298
+ call(
299
+ ROUTES.controlWake,
300
+ { id },
301
+ typeof reason === "string" && reason !== "" ? { lane, reason } : { lane },
302
+ ),
303
+ controlDrain: (id) => call(ROUTES.controlDrain, { id }, {}),
304
+ schedulers: (id) => call(ROUTES.schedulers, { id }),
305
+
306
+ // ---- M2: fleet inboxes ------------------------------------------------
307
+ approvals: (all = false) => call(ROUTES.approvals, {}, undefined, all ? "?all=1" : ""),
308
+ grantApproval: (id, apprId, by) =>
309
+ call(ROUTES.grantApproval, { id, apprId }, typeof by === "string" && by !== "" ? { by } : {}),
310
+ denyApproval: (id, apprId, by) =>
311
+ call(ROUTES.denyApproval, { id, apprId }, typeof by === "string" && by !== "" ? { by } : {}),
312
+ review: (all = false) => call(ROUTES.review, {}, undefined, all ? "?all=1" : ""),
313
+ adjudicateReview: (id, itemId, verdict, note) =>
314
+ call(
315
+ ROUTES.adjudicateReview,
316
+ { id, itemId },
317
+ typeof note === "string" && note !== "" ? { verdict, note } : { verdict },
318
+ ),
319
+ activity: (since) =>
320
+ call(
321
+ ROUTES.activity,
322
+ {},
323
+ undefined,
324
+ typeof since === "string" && since !== "" ? `?since=${encodeURIComponent(since)}` : "",
325
+ ),
326
+
327
+ // ---- M2: jobs + the remaining action faces ----------------------------
328
+ jobs: () => call(ROUTES.jobs, {}),
329
+ submitJob: (id, kind, options) =>
330
+ call(
331
+ ROUTES.submitJob,
332
+ { id },
333
+ { kind, ...(options && typeof options === "object" ? options : {}) },
334
+ ),
335
+ deployments: (id) => call(ROUTES.deployments, { id }),
336
+ pinSession: (id, sess, pinned) => call(ROUTES.pinSession, { id, sess }, { pinned }),
337
+ pinBaseline: (id, runId) => call(ROUTES.pinBaseline, { id }, { runId }),
338
+
339
+ // ---- M4: health, onboarding, ⌘K, notifications, read-only, plugins ----
340
+ health: (id) => call(ROUTES.health, { id }),
341
+ fleetHealth: () => call(ROUTES.fleetHealth, {}),
342
+ onboarding: () => call(ROUTES.onboarding, {}),
343
+ // A refusal here is a PAYLOAD (409 `no-demos-checkout` / `refused`), not an
344
+ // error: the reason and the remedy are the whole point of the screen.
345
+ demoInstall: (starter, dir) => callTyped(ROUTES.demoInstall, {}, { starter, dir }),
346
+ search: (q, limit) =>
347
+ call(
348
+ ROUTES.search,
349
+ {},
350
+ undefined,
351
+ `?q=${encodeURIComponent(String(q ?? ""))}${
352
+ typeof limit === "number" ? `&limit=${limit}` : ""
353
+ }`,
354
+ ),
355
+ notifications: () => call(ROUTES.notifications, {}),
356
+ setNotifications: (settings) => call(ROUTES.setNotifications, {}, settings ?? {}),
357
+ clearNotifications: () => call(ROUTES.clearNotifications, {}, {}),
358
+ readOnly: () => call(ROUTES.readOnly, {}),
359
+ // 409 `locked` when the manager was started read-only — a fact to render,
360
+ // not a transport error.
361
+ setReadOnly: (enabled) => callTyped(ROUTES.setReadOnly, {}, { enabled: enabled === true }),
362
+ plugins: () => call(ROUTES.plugins, {}),
363
+ pluginPane: (plugin, pane) => call(ROUTES.pluginPane, { plugin, pane }),
364
+ panes: (id) => call(ROUTES.panes, { id }),
365
+
366
+ // cross-cutting
367
+ version: () => call(ROUTES.version, {}),
368
+ };