@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,610 @@
1
+ /**
2
+ * Credentials — the per-harness `.env` editor (presence only), the doctor,
3
+ * the secrets backend, and the MCP config lint. Plus the fleet-wide
4
+ * credential matrix and its set-across action.
5
+ *
6
+ * VALUES ARE WRITE-ONLY, and this screen is where that promise is most
7
+ * visible: a field accepts a value and the panel immediately re-reads
8
+ * PRESENCE. Nothing here ever displays a secret, because nothing here ever
9
+ * receives one back — the server returns booleans. The input is cleared the
10
+ * instant the write returns, so the value does not even survive in the DOM.
11
+ *
12
+ * "Unset" writes a `# NAME=` stub rather than removing the line, so a
13
+ * required key stays visible instead of quietly disappearing from the
14
+ * checklist.
15
+ *
16
+ * Set-across is the sharpest tool in the manager: one typed value written
17
+ * into several harnesses' own `.env` files, typed-confirm gated (the
18
+ * operator echoes the KEY, and the SERVER verifies it), reported per
19
+ * harness, stored nowhere.
20
+ */
21
+
22
+ import { api } from "../api.js";
23
+ import { clear, collapsible, dot, el, emptyState, jsonPre, skeleton, toast } from "../dom.js";
24
+ import { fmtRelativeTime } from "../util.js";
25
+
26
+ /**
27
+ * Read one M3 route. The generated wrappers answer `{ ok, status, body }`
28
+ * and never throw for a non-2xx, because an M3 refusal is the interesting
29
+ * part: a 501 means "not built yet", and that is a state to render, not an
30
+ * exception to swallow.
31
+ */
32
+ export async function m3(call) {
33
+ try {
34
+ const answer = await call;
35
+ return answer && typeof answer === "object" ? answer : { ok: false, status: 0, body: {} };
36
+ } catch (err) {
37
+ return { ok: false, status: 0, body: { message: err instanceof Error ? err.message : "" } };
38
+ }
39
+ }
40
+
41
+ /** A card whose body is replaced by the server's own reason when a route is
42
+ * not implemented or refused. "Not built yet" is a fact, not a blank. */
43
+ export function m3Card(title, answer, build) {
44
+ const card = el("section", { class: "card" }, [el("h3", { class: "card-title", text: title })]);
45
+ if (!answer.ok) {
46
+ const reason =
47
+ (answer.body && (answer.body.error || answer.body.message)) || `HTTP ${answer.status}`;
48
+ card.appendChild(
49
+ el("div", { class: "rollup" }, [
50
+ dot(
51
+ answer.status === 501 ? "off" : "bad",
52
+ answer.status === 501 ? "not built yet" : "unavailable",
53
+ ),
54
+ ]),
55
+ );
56
+ card.appendChild(el("p", { class: "muted", text: String(reason) }));
57
+ return card;
58
+ }
59
+ for (const node of build(answer.body)) if (node) card.appendChild(node);
60
+ return card;
61
+ }
62
+
63
+ /** The empty state every M3 read carries with it: the server names both the
64
+ * reason and the verb that would create the data. */
65
+ export function m3Empty(body, fallback) {
66
+ return emptyState(
67
+ typeof body.note === "string" && body.note !== "" ? body.note : fallback,
68
+ typeof body.verb === "string" ? body.verb : null,
69
+ );
70
+ }
71
+
72
+ /** Traffic-light state for one credential cell. ALWAYS paired with text. */
73
+ export function cellDot(state) {
74
+ if (state === "set") return dot("ok", "set");
75
+ if (state === "missing") return dot("bad", "missing");
76
+ if (state === "commented-stub") return dot("warn", "stub");
77
+ return dot("off", "informational");
78
+ }
79
+
80
+ /** Raw payload fold, for the operator who wants the server's own answer. */
81
+ export function rawFold(body) {
82
+ return collapsible([el("span", { class: "muted", text: "raw payload" })], [jsonPre(body)]);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // The per-harness Credentials tab
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export async function renderCreds(root, ctx) {
90
+ clear(root).appendChild(skeleton(6));
91
+ const [envAnswer, doctorAnswer, secretsAnswer, secretsDoctorAnswer, lintAnswer] =
92
+ await Promise.all([
93
+ m3(api.env({ id: ctx.id })),
94
+ m3(api.doctor({ id: ctx.id })),
95
+ m3(api.secrets({ id: ctx.id })),
96
+ m3(api.secretsDoctor({ id: ctx.id })),
97
+ m3(api.mcpLint({ id: ctx.id })),
98
+ ]);
99
+ clear(root);
100
+ const reload = () => renderCreds(root, ctx);
101
+
102
+ root.appendChild(
103
+ m3Card("Required credentials", envAnswer, (body) => envCards(body, ctx, reload)),
104
+ );
105
+ root.appendChild(m3Card("Doctor", doctorAnswer, (body) => doctorCard(body, ctx, reload)));
106
+ root.appendChild(
107
+ m3Card("Secrets backend", secretsAnswer, (body) =>
108
+ secretsCard(body, secretsDoctorAnswer.ok ? secretsDoctorAnswer.body : null),
109
+ ),
110
+ );
111
+ root.appendChild(m3Card("MCP config lint", lintAnswer, (body) => lintCard(body)));
112
+ }
113
+
114
+ function envCards(body, ctx, reload) {
115
+ const keys = Array.isArray(body.keys) ? body.keys : [];
116
+ const counts = body.counts && typeof body.counts === "object" ? body.counts : {};
117
+ const nodes = [
118
+ el("div", { class: "rollup" }, [
119
+ dot(Number(counts.missing) > 0 ? "bad" : "ok", `${Number(counts.set) || 0} set`),
120
+ el("span", { class: "chip", text: `${Number(counts.missing) || 0} missing` }),
121
+ el("span", { class: "chip", text: `${Number(counts.commentedStub) || 0} stubs` }),
122
+ el("span", { class: "chip", text: `${Number(counts.informational) || 0} informational` }),
123
+ ]),
124
+ ];
125
+
126
+ // Precedence is SHOWN, not implied: an operator with both variables set
127
+ // needs to know which one the adapter will actually use.
128
+ const anthropic = body.anthropic && typeof body.anthropic === "object" ? body.anthropic : null;
129
+ if (anthropic) {
130
+ nodes.push(
131
+ el("p", { class: "muted" }, [
132
+ el("span", { text: "Anthropic auth: " }),
133
+ el("strong", { text: String(anthropic.winner ?? "none") }),
134
+ el("span", { text: ` (${String(anthropic.mode)}) — ${String(anthropic.note)}` }),
135
+ ]),
136
+ );
137
+ }
138
+
139
+ nodes.push(
140
+ keys.length === 0
141
+ ? m3Empty(body, "No credential requirement is declared by this spec")
142
+ : keyTable(keys, ctx, reload),
143
+ );
144
+ nodes.push(
145
+ el("p", { class: "muted", text: `writes go to ${String(body.editTarget ?? ".env")} (0600)` }),
146
+ );
147
+
148
+ const ambient = Array.isArray(body.ambient) ? body.ambient : [];
149
+ if (ambient.length > 0) {
150
+ nodes.push(
151
+ collapsible(
152
+ [el("span", { class: "muted", text: `${ambient.length} ambient credential chain(s)` })],
153
+ [
154
+ el(
155
+ "ul",
156
+ { class: "check-list" },
157
+ ambient.map((entry) =>
158
+ el("li", null, [
159
+ el("code", { text: String(entry.requiredBy) }),
160
+ el("span", { class: "muted", text: ` — ${String(entry.detail)}` }),
161
+ ]),
162
+ ),
163
+ ),
164
+ ],
165
+ ),
166
+ );
167
+ }
168
+
169
+ const orphans = Array.isArray(body.orphanStubs) ? body.orphanStubs : [];
170
+ if (orphans.length > 0) {
171
+ nodes.push(
172
+ el("p", { class: "muted", text: `stubs no requirement asks for: ${orphans.join(", ")}` }),
173
+ );
174
+ }
175
+ return nodes;
176
+ }
177
+
178
+ function keyTable(keys, ctx, reload) {
179
+ const tbody = el("tbody");
180
+ for (const key of keys) {
181
+ tbody.appendChild(
182
+ el("tr", null, [
183
+ el("td", { class: "mono cell-name", text: String(key.name) }),
184
+ el("td", null, [cellDot(String(key.state))]),
185
+ el("td", { class: "muted", text: key.source === null ? "—" : String(key.source) }),
186
+ el("td", { class: "muted sub", text: (key.requiredBy || []).join(", ") }),
187
+ el("td", { class: "cell-actions" }, [setControl(ctx, String(key.name), reload)]),
188
+ ]),
189
+ );
190
+ }
191
+ return el("div", { class: "table-scroll" }, [
192
+ el("table", { class: "fleet" }, [
193
+ el(
194
+ "thead",
195
+ null,
196
+ el(
197
+ "tr",
198
+ null,
199
+ ["Variable", "State", "Supplied by", "Required by", "Set / unset"].map((h) =>
200
+ el("th", { text: h }),
201
+ ),
202
+ ),
203
+ ),
204
+ tbody,
205
+ ]),
206
+ ]);
207
+ }
208
+
209
+ /**
210
+ * The one write control in this screen. The input is `type="password"` with
211
+ * autocomplete off, and it is CLEARED the moment the write returns — the
212
+ * value has no reason to outlive the request, in the process or the DOM.
213
+ */
214
+ function setControl(ctx, name, reload) {
215
+ const input = el("input", {
216
+ class: "input",
217
+ type: "password",
218
+ autocomplete: "off",
219
+ spellcheck: "false",
220
+ placeholder: "value (write-only)",
221
+ "aria-label": `value for ${name}`,
222
+ });
223
+ const set = el("button", { class: "btn btn-primary", type: "button", text: "Set" });
224
+ set.addEventListener("click", async () => {
225
+ const value = input.value;
226
+ if (value === "") {
227
+ toast("enter a value first", "info");
228
+ return;
229
+ }
230
+ set.disabled = true;
231
+ const answer = await m3(api.envSet({ id: ctx.id }, { key: name, value }));
232
+ // Cleared FIRST, whatever happened — a failed write is no reason to keep
233
+ // a secret sitting in a DOM node.
234
+ input.value = "";
235
+ set.disabled = false;
236
+ if (!answer.ok) {
237
+ toast(`could not set ${name}: ${String(answer.body.error ?? answer.status)}`);
238
+ return;
239
+ }
240
+ reload();
241
+ });
242
+ const unset = el("button", {
243
+ class: "btn btn-ghost",
244
+ type: "button",
245
+ text: "Unset",
246
+ title: `rewrite ${name} as a "# ${name}=" stub — the requirement stays visible`,
247
+ });
248
+ unset.addEventListener("click", async () => {
249
+ unset.disabled = true;
250
+ const answer = await m3(api.envUnset({ id: ctx.id, key: name }));
251
+ unset.disabled = false;
252
+ if (!answer.ok) {
253
+ toast(`could not unset ${name}: ${String(answer.body.error ?? answer.status)}`);
254
+ return;
255
+ }
256
+ reload();
257
+ });
258
+ return el("div", { class: "row-editor" }, [input, set, unset]);
259
+ }
260
+
261
+ function doctorCard(body, ctx, reload) {
262
+ const checks = Array.isArray(body.checks) ? body.checks : [];
263
+ const counts = body.counts && typeof body.counts === "object" ? body.counts : {};
264
+ const nodes = [
265
+ el("div", { class: "rollup" }, [
266
+ dot(Number(counts.blocking) > 0 ? "bad" : "ok", `${Number(counts.blocking) || 0} blocking`),
267
+ el("span", { class: "chip", text: `${Number(counts.warn) || 0} warn` }),
268
+ el("span", { class: "chip", text: `${Number(counts.info) || 0} info` }),
269
+ ]),
270
+ ];
271
+ nodes.push(
272
+ checks.length === 0
273
+ ? m3Empty(body, "The offline doctor has nothing to check here")
274
+ : el(
275
+ "ul",
276
+ { class: "check-list" },
277
+ checks.map((check) =>
278
+ el("li", { class: "check" }, [
279
+ dot(
280
+ check.level === "blocking" ? "bad" : check.level === "warn" ? "warn" : "ok",
281
+ String(check.level),
282
+ ),
283
+ el("span", { class: "pf-msg", text: String(check.message) }),
284
+ check.remediation
285
+ ? el("div", { class: "muted reason", text: String(check.remediation) })
286
+ : null,
287
+ ]),
288
+ ),
289
+ ),
290
+ );
291
+
292
+ const last = body.lastRun && typeof body.lastRun === "object" ? body.lastRun : null;
293
+ nodes.push(
294
+ el("p", {
295
+ class: "muted",
296
+ text:
297
+ last === null
298
+ ? "no doctor job has run from this manager yet"
299
+ : `last run ${String(last.state)}${
300
+ last.endedAt ? ` ${fmtRelativeTime(last.endedAt, Date.now())}` : ""
301
+ }${last.probed ? " (with --probe)" : ""}`,
302
+ }),
303
+ );
304
+
305
+ // `--probe` spends money and `--fix` writes to `.env`, so both are opt-in
306
+ // checkboxes beside the button rather than surprises behind it.
307
+ const probe = el("input", { type: "checkbox" });
308
+ const fix = el("input", { type: "checkbox" });
309
+ const run = el("button", { class: "btn", type: "button", text: "Run doctor" });
310
+ run.addEventListener("click", async () => {
311
+ run.disabled = true;
312
+ const answer = await m3(
313
+ api.doctorRun({ id: ctx.id }, { probe: probe.checked, fix: fix.checked }),
314
+ );
315
+ run.disabled = false;
316
+ if (!answer.ok) {
317
+ toast(`doctor failed to start: ${String(answer.body.error ?? answer.status)}`);
318
+ return;
319
+ }
320
+ toast("doctor queued", "info");
321
+ reload();
322
+ });
323
+ nodes.push(
324
+ el("div", { class: "editor-actions" }, [
325
+ run,
326
+ el("label", { class: "muted" }, [
327
+ probe,
328
+ el("span", { text: " --probe (spends a provider call)" }),
329
+ ]),
330
+ el("label", { class: "muted" }, [
331
+ fix,
332
+ el("span", { text: " --fix (appends # NAME= stubs only)" }),
333
+ ]),
334
+ ]),
335
+ );
336
+ if (typeof body.probeNote === "string") {
337
+ nodes.push(el("p", { class: "muted", text: body.probeNote }));
338
+ }
339
+ return nodes;
340
+ }
341
+
342
+ function secretsCard(body, doctorBody) {
343
+ const names = Array.isArray(body.names) ? body.names : [];
344
+ const nodes = [
345
+ el("div", { class: "rollup" }, [
346
+ dot(body.backend === "file" ? "ok" : "off", `${String(body.backend ?? "env-var")} backend`),
347
+ el("span", { class: "chip", text: `${names.length} secret(s)` }),
348
+ ]),
349
+ ];
350
+ if (names.length === 0) {
351
+ nodes.push(m3Empty(body, "No file-backed secrets store"));
352
+ } else {
353
+ const tbody = el("tbody");
354
+ for (const entry of names) {
355
+ tbody.appendChild(
356
+ el("tr", null, [
357
+ el("td", { class: "mono", text: String(entry.name) }),
358
+ el("td", {
359
+ class: "muted",
360
+ text: entry.rotatedAt ? fmtRelativeTime(entry.rotatedAt, Date.now()) : "—",
361
+ }),
362
+ el("td", null, [
363
+ entry.mode === "0600"
364
+ ? dot("ok", "0600")
365
+ : dot("warn", `${String(entry.mode)} — wider than owner-only`),
366
+ ]),
367
+ ]),
368
+ );
369
+ }
370
+ nodes.push(
371
+ el("div", { class: "table-scroll" }, [
372
+ el("table", { class: "fleet" }, [
373
+ el(
374
+ "thead",
375
+ null,
376
+ el(
377
+ "tr",
378
+ null,
379
+ ["Name", "Last rotated", "Mode"].map((h) => el("th", { text: h })),
380
+ ),
381
+ ),
382
+ tbody,
383
+ ]),
384
+ ]),
385
+ );
386
+ }
387
+ // Rotation is deliberately NOT offered: the console cannot rotate safely
388
+ // in this build, and a button that leaks a value into a process table is
389
+ // worse than no button. The reason is on screen, not hidden.
390
+ nodes.push(
391
+ el("div", { class: "gated" }, [
392
+ el("button", { class: "btn", type: "button", text: "Rotate", disabled: true }),
393
+ el("span", {
394
+ class: "muted gated-why",
395
+ text: "rotation is not wired in this build — the CLI path would put the value in argv, where any process on this machine can read it",
396
+ }),
397
+ ]),
398
+ );
399
+ if (doctorBody) {
400
+ const unresolved = Array.isArray(doctorBody.unresolved) ? doctorBody.unresolved : [];
401
+ nodes.push(
402
+ el("p", {
403
+ class: "muted",
404
+ text:
405
+ unresolved.length === 0
406
+ ? "every required name resolves from the environment or the store"
407
+ : `${unresolved.length} required name(s) resolve from neither the environment nor the store: ${unresolved
408
+ .map((entry) => String(entry.name))
409
+ .join(", ")}`,
410
+ }),
411
+ );
412
+ }
413
+ if (typeof body.valuesNote === "string") {
414
+ nodes.push(el("p", { class: "muted", text: body.valuesNote }));
415
+ }
416
+ return nodes;
417
+ }
418
+
419
+ function lintCard(body) {
420
+ const findings = Array.isArray(body.findings) ? body.findings : [];
421
+ const servers = Array.isArray(body.servers) ? body.servers : [];
422
+ const nodes = [
423
+ el("div", { class: "rollup" }, [
424
+ dot(
425
+ findings.some((finding) => finding.level === "blocking") ? "bad" : "ok",
426
+ `${servers.length} MCP server(s)`,
427
+ ),
428
+ ]),
429
+ ];
430
+ if (findings.length === 0) {
431
+ nodes.push(m3Empty(body, "Nothing to lint — no MCP server config here"));
432
+ return nodes;
433
+ }
434
+ nodes.push(
435
+ el(
436
+ "ul",
437
+ { class: "check-list" },
438
+ findings.map((finding) =>
439
+ el("li", { class: "check" }, [
440
+ dot(finding.level === "blocking" ? "bad" : "warn", String(finding.kind)),
441
+ el("span", { class: "pf-msg", text: String(finding.message) }),
442
+ finding.remediation
443
+ ? el("div", { class: "muted reason", text: String(finding.remediation) })
444
+ : null,
445
+ ]),
446
+ ),
447
+ ),
448
+ );
449
+ return nodes;
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // The fleet-wide credentials matrix (`#/credentials`)
454
+ // ---------------------------------------------------------------------------
455
+
456
+ export async function renderCredentialsMatrix(root) {
457
+ clear(root).appendChild(skeleton(6));
458
+ const answer = await m3(api.credentialsMatrix());
459
+ clear(root);
460
+ const reload = () => renderCredentialsMatrix(root);
461
+ root.appendChild(m3Card("Fleet credentials", answer, (body) => matrixCards(body, reload)));
462
+ }
463
+
464
+ function matrixCards(body, reload) {
465
+ const harnesses = Array.isArray(body.harnesses) ? body.harnesses : [];
466
+ const keys = Array.isArray(body.keys) ? body.keys : [];
467
+ if (harnesses.length === 0) {
468
+ return [m3Empty(body, "No harnesses are registered with this manager")];
469
+ }
470
+ return [
471
+ el("div", { class: "rollup" }, [
472
+ dot(
473
+ harnesses.some((row) => Number(row.missing) > 0) ? "bad" : "ok",
474
+ `${harnesses.length} harnesses · ${keys.length} credential names`,
475
+ ),
476
+ // A fold computed for THIS request — labelled, so the figure never
477
+ // implies a freshness it does not have.
478
+ el("span", {
479
+ class: "asof",
480
+ title: String(body.asOf ?? ""),
481
+ text: `as of ${fmtRelativeTime(body.asOf, Date.now())}`,
482
+ }),
483
+ ]),
484
+ el("p", { class: "muted", text: String(body.valuesNote ?? "") }),
485
+ matrixTable(harnesses, keys),
486
+ setAcrossForm(harnesses, keys, reload),
487
+ ];
488
+ }
489
+
490
+ function matrixTable(harnesses, keys) {
491
+ const head = el("tr", null, [
492
+ el("th", { text: "Harness" }),
493
+ ...keys.map((key) => el("th", { class: "mono", text: String(key.name) })),
494
+ ]);
495
+ const tbody = el("tbody");
496
+ for (const row of harnesses) {
497
+ const cells = new Map(
498
+ (Array.isArray(row.cells) ? row.cells : []).map((cell) => [String(cell.name), cell]),
499
+ );
500
+ tbody.appendChild(
501
+ el("tr", null, [
502
+ el("td", null, [
503
+ el("span", { class: "name-line", text: String(row.specName) }),
504
+ el("div", { class: "mono sub", text: String(row.id) }),
505
+ ]),
506
+ ...keys.map((key) => {
507
+ const cell = cells.get(String(key.name));
508
+ return el("td", null, [
509
+ cell === undefined
510
+ ? el("span", { class: "muted", text: "not required" })
511
+ : cellDot(String(cell.state)),
512
+ ]);
513
+ }),
514
+ ]),
515
+ );
516
+ }
517
+ return el("div", { class: "table-scroll" }, [
518
+ el("table", { class: "fleet" }, [el("thead", null, head), tbody]),
519
+ ]);
520
+ }
521
+
522
+ /**
523
+ * Set-across. The most dangerous affordance in the manager, so it wears its
524
+ * gates in the open: the operator picks the harnesses explicitly and types
525
+ * the KEY NAME back — which the SERVER verifies, not this form.
526
+ */
527
+ function setAcrossForm(harnesses, keys, reload) {
528
+ const keyInput = el("input", {
529
+ class: "input mono",
530
+ type: "text",
531
+ placeholder: "VARIABLE_NAME",
532
+ "aria-label": "variable name",
533
+ list: "cred-keys",
534
+ });
535
+ const datalist = el(
536
+ "datalist",
537
+ { id: "cred-keys" },
538
+ keys.map((key) => el("option", { value: String(key.name) })),
539
+ );
540
+ const valueInput = el("input", {
541
+ class: "input",
542
+ type: "password",
543
+ autocomplete: "off",
544
+ placeholder: "value (write-only)",
545
+ "aria-label": "value",
546
+ });
547
+ const confirmInput = el("input", {
548
+ class: "input mono",
549
+ type: "text",
550
+ placeholder: "type the variable name to confirm",
551
+ "aria-label": "typed confirmation",
552
+ });
553
+ const boxes = harnesses.map((row) => {
554
+ const box = el("input", { type: "checkbox" });
555
+ box.dataset.id = String(row.id);
556
+ return el("label", { class: "chip" }, [box, el("span", { text: ` ${String(row.specName)}` })]);
557
+ });
558
+ const apply = el("button", {
559
+ class: "btn btn-danger",
560
+ type: "button",
561
+ text: "Set across selected",
562
+ });
563
+ apply.addEventListener("click", async () => {
564
+ const harnessIds = boxes
565
+ .map((label) => label.firstChild)
566
+ .filter((box) => box.checked)
567
+ .map((box) => box.dataset.id);
568
+ if (harnessIds.length === 0) {
569
+ toast("select at least one harness", "info");
570
+ return;
571
+ }
572
+ apply.disabled = true;
573
+ const answer = await m3(
574
+ api.credentialsSetAcross(
575
+ {},
576
+ {
577
+ key: keyInput.value,
578
+ value: valueInput.value,
579
+ harnessIds,
580
+ confirmName: confirmInput.value,
581
+ },
582
+ ),
583
+ );
584
+ valueInput.value = "";
585
+ apply.disabled = false;
586
+ if (!answer.ok) {
587
+ toast(String(answer.body.error ?? `HTTP ${answer.status}`));
588
+ return;
589
+ }
590
+ const written = Array.isArray(answer.body.written) ? answer.body.written.length : 0;
591
+ const refused = Array.isArray(answer.body.refused) ? answer.body.refused : [];
592
+ toast(
593
+ `wrote ${written} harness(es)${refused.length > 0 ? `, ${refused.length} refused` : ""}`,
594
+ refused.length > 0 ? "error" : "info",
595
+ );
596
+ reload();
597
+ });
598
+ return el("section", { class: "card" }, [
599
+ el("h3", { class: "card-title" }, [
600
+ el("span", { text: "Set one variable across selected harnesses" }),
601
+ dot("warn", "typed confirmation required"),
602
+ ]),
603
+ el("p", {
604
+ class: "muted",
605
+ text: "each harness is written through its own .env; this manager keeps no copy of the value",
606
+ }),
607
+ el("div", { class: "chip-group" }, boxes),
608
+ el("div", { class: "row-editor" }, [datalist, keyInput, valueInput, confirmInput, apply]),
609
+ ]);
610
+ }