@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,155 @@
1
+ /**
2
+ * The approvals inbox — every harness's parked tool-approval requests folded
3
+ * into one triage list.
4
+ *
5
+ * THE INPUT IS SHOWN VERBATIM (the server masked credential shapes on the
6
+ * way out, and nothing else): an approver cannot judge a call they cannot
7
+ * see, so the tool input renders in full rather than as a hash or a summary.
8
+ *
9
+ * Triage is keyboard-first — j/k to move, g/d to grant/deny, `.` for the row
10
+ * menu, `?` for the bindings — because an inbox with twenty parked calls is
11
+ * a keyboard task. Granting a request whose run PARKED also resumes it: the
12
+ * row says so, because "what happens after I click" is the question an
13
+ * approver actually has.
14
+ */
15
+
16
+ import { api } from "../api.js";
17
+ import { clear, dot, el, emptyState, jsonPre, skeleton, toast } from "../dom.js";
18
+ import { hrefHarness } from "../router.js";
19
+ import { APPROVAL_KEYS, approvalRow } from "../supervision.js";
20
+ import { actionTwin, gatedBtn } from "./control.js";
21
+ import { mountTriage } from "./inbox.js";
22
+
23
+ export async function renderApprovals(root) {
24
+ clear(root).appendChild(skeleton(6));
25
+ await draw(root, false);
26
+ }
27
+
28
+ async function draw(host, showSettled) {
29
+ let payload;
30
+ try {
31
+ payload = await api.approvals(showSettled);
32
+ } catch (err) {
33
+ clear(host).appendChild(
34
+ el("div", { class: "card error-card" }, [
35
+ el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
36
+ ]),
37
+ );
38
+ return;
39
+ }
40
+ clear(host);
41
+ const nowMs = Date.now();
42
+ const rows = (payload && Array.isArray(payload.approvals) ? payload.approvals : []).map((r) =>
43
+ approvalRow(r, nowMs),
44
+ );
45
+ const pending = payload && typeof payload.pending === "number" ? payload.pending : 0;
46
+ const truncated =
47
+ payload && Array.isArray(payload.truncatedHarnesses) ? payload.truncatedHarnesses : [];
48
+ const reload = () => draw(host, showSettled);
49
+
50
+ host.appendChild(
51
+ el("div", { class: "toolbar" }, [
52
+ el("span", { class: "rollup", text: `${pending} pending · ${rows.length} shown` }),
53
+ el("span", { class: "spacer" }),
54
+ el("button", {
55
+ class: `btn btn-ghost${showSettled ? " active" : ""}`,
56
+ type: "button",
57
+ text: showSettled ? "Showing settled too" : "Show settled",
58
+ onClick: () => draw(host, !showSettled),
59
+ }),
60
+ ]),
61
+ );
62
+ if (truncated.length > 0) {
63
+ host.appendChild(
64
+ el("p", {
65
+ class: "muted",
66
+ text: `${truncated.length} harness log${truncated.length === 1 ? " was" : "s were"} capped mid-read — older entries are not shown.`,
67
+ }),
68
+ );
69
+ }
70
+ if (rows.length === 0) {
71
+ host.appendChild(
72
+ emptyState(
73
+ showSettled ? "No approvals recorded on this fleet" : "Nothing waiting for a decision",
74
+ "crewhaus approvals list",
75
+ ),
76
+ );
77
+ return;
78
+ }
79
+ mountTriage(host, rows, {
80
+ keys: APPROVAL_KEYS,
81
+ reload,
82
+ render: approvalCard,
83
+ perform: decide,
84
+ });
85
+ }
86
+
87
+ async function decide(row, action, reload) {
88
+ const grant = action === "grant";
89
+ try {
90
+ await (grant
91
+ ? api.grantApproval(row.harnessId, row.id)
92
+ : api.denyApproval(row.harnessId, row.id));
93
+ } catch (err) {
94
+ toast(
95
+ `${grant ? "Grant" : "Deny"} failed: ${err instanceof Error ? err.message : String(err)}`,
96
+ );
97
+ return;
98
+ }
99
+ toast(`${grant ? "Granted" : "Denied"} ${row.toolName}`, "info");
100
+ reload();
101
+ }
102
+
103
+ function approvalCard(row, index, state, redraw, reload) {
104
+ const selected = state.index === index;
105
+ const gate = { enabled: row.pending, reason: `already ${row.status}` };
106
+ return el(
107
+ "section",
108
+ {
109
+ class: `card inbox-card${selected ? " selected" : ""}`,
110
+ "aria-current": selected ? "true" : null,
111
+ onClick: () => {
112
+ if (!selected) redraw({ ...state, index });
113
+ },
114
+ },
115
+ [
116
+ el("div", { class: "inbox-head" }, [
117
+ dot(row.dot, row.status),
118
+ el("span", { class: "chip chip-tool", text: row.toolName }),
119
+ el("a", { class: "name-link", href: hrefHarness(row.harnessId), text: row.specName }),
120
+ el("span", { class: "chip", text: row.surface }),
121
+ el("span", { class: "muted", text: row.age }),
122
+ ]),
123
+ // Verbatim (already masked) — the whole point of an approval screen.
124
+ jsonPre(row.input),
125
+ row.parkedRun !== null
126
+ ? el("div", { class: "muted reason" }, [
127
+ el("span", { text: "parked run " }),
128
+ el("a", {
129
+ class: "mono",
130
+ href: hrefHarness(row.harnessId, "runs", row.parkedRun.runId),
131
+ text: row.parkedRun.runId,
132
+ }),
133
+ el("span", { text: " · session " }),
134
+ el("a", {
135
+ class: "mono",
136
+ href: hrefHarness(row.harnessId, "sessions", row.parkedRun.sessionId),
137
+ text: row.parkedRun.sessionId,
138
+ }),
139
+ ])
140
+ : null,
141
+ row.resumeNote !== null ? el("div", { class: "muted reason", text: row.resumeNote }) : null,
142
+ row.decidedBy !== null
143
+ ? el("div", { class: "muted reason", text: `decided by ${row.decidedBy}` })
144
+ : null,
145
+ el("div", { class: "inbox-actions" }, [
146
+ gatedBtn("Grant", gate, () => decide(row, "grant", reload), "btn btn-primary"),
147
+ gatedBtn("Deny", gate, () => decide(row, "deny", reload), "btn btn-danger"),
148
+ ]),
149
+ el("div", { class: "inbox-twins" }, [
150
+ actionTwin("grant", { id: row.id }),
151
+ actionTwin("deny", { id: row.id }),
152
+ ]),
153
+ ],
154
+ );
155
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Channels — provisioning, verification, the two testing tiers, routing
3
+ * display, per-channel status, and the gateway panel. Shape-gated: this tab
4
+ * belongs to channel daemons and says so when it is empty.
5
+ *
6
+ * The two test buttons are labelled for what they actually prove:
7
+ * tier 1 an unsigned POST expecting a 401 — "the endpoint is up and
8
+ * rejecting unsigned traffic". Cheap, and NOT end-to-end.
9
+ * tier 2 a SIGNED synthetic inbound, signed server-side with the
10
+ * harness's own secret, driving a full turn. Slack, Telegram and
11
+ * WhatsApp only — Discord's verification is asymmetric Ed25519, so
12
+ * the harness holds only a public key and a forged event is
13
+ * impossible by design; iMessage is a poller with no inbound
14
+ * webhook. Both exclusions render DISABLED WITH THE REASON, which
15
+ * is more useful than hiding the button and leaving the operator
16
+ * to wonder why it is gone.
17
+ *
18
+ * The routing panel carries the reactions caveat: emoji feedback needs a
19
+ * `channel` or `user` sessionKey, and a `thread` sessionKey silently
20
+ * collects nothing.
21
+ *
22
+ * A green light is never drawn from spec configuration. "Configured" and
23
+ * "serving" are separate lights, and the second stays unknown-with-a-reason
24
+ * unless the gateway's own `/status` answered.
25
+ */
26
+
27
+ import { api } from "../api.js";
28
+ import { clear, collapsible, dot, el, jsonPre, numberedCode, skeleton, toast } from "../dom.js";
29
+ import { m3, m3Card, m3Empty } from "./creds.js";
30
+
31
+ export async function renderChannels(root, ctx) {
32
+ clear(root).appendChild(skeleton(5));
33
+ const [channelsAnswer, gatewayAnswer] = await Promise.all([
34
+ m3(api.channels({ id: ctx.id })),
35
+ m3(api.gateway({ id: ctx.id })),
36
+ ]);
37
+ clear(root);
38
+ const reload = () => renderChannels(root, ctx);
39
+
40
+ root.appendChild(m3Card("Channels", channelsAnswer, (body) => channelCards(body, ctx, reload)));
41
+ root.appendChild(m3Card("Gateway", gatewayAnswer, (body) => gatewayCard(body)));
42
+ }
43
+
44
+ function channelCards(body, ctx, reload) {
45
+ const rows = Array.isArray(body.channels) ? body.channels : [];
46
+ const nodes = [];
47
+
48
+ // A value the compiler REJECTS outranks every env check beside it: no
49
+ // daemon can exist at all, so this leads.
50
+ const compileErrors = Array.isArray(body.compileErrors) ? body.compileErrors : [];
51
+ if (compileErrors.length > 0) {
52
+ nodes.push(
53
+ el("div", { class: "rollup" }, [dot("bad", "this spec will not compile")]),
54
+ el(
55
+ "ul",
56
+ { class: "check-list" },
57
+ compileErrors.map((error) =>
58
+ el("li", { class: "check" }, [
59
+ el("code", { text: String(error.label) }),
60
+ el("div", { class: "muted reason", text: String(error.message) }),
61
+ ]),
62
+ ),
63
+ ),
64
+ );
65
+ }
66
+
67
+ nodes.push(routingRow(body.routing));
68
+
69
+ if (rows.length === 0) {
70
+ nodes.push(m3Empty(body, "This spec declares no channels"));
71
+ return nodes;
72
+ }
73
+
74
+ // Live connectivity is the gateway's own answer, or an honest absence.
75
+ const live = Array.isArray(body.liveChannels) ? body.liveChannels : null;
76
+ nodes.push(
77
+ el("div", { class: "rollup" }, [
78
+ live === null
79
+ ? dot("off", "no live status")
80
+ : dot("ok", `serving: ${live.length === 0 ? "none" : live.join(", ")}`),
81
+ typeof body.statusReason === "string" && body.statusReason !== ""
82
+ ? el("span", { class: "muted", text: body.statusReason })
83
+ : null,
84
+ live !== null ? el("span", { class: "chip", text: String(body.statusSource) }) : null,
85
+ ]),
86
+ );
87
+
88
+ nodes.push(verifyControls(ctx, reload));
89
+ for (const row of rows) nodes.push(channelCard(row, live, ctx, reload));
90
+ return nodes;
91
+ }
92
+
93
+ /**
94
+ * The pre-boot gate, run on demand. Offline is answered inline (a pure
95
+ * function of the spec and the environment), so it re-reads the panel; a
96
+ * LIVE verify makes real platform calls and therefore goes through the job
97
+ * queue — the button says which is which rather than hiding the difference.
98
+ */
99
+ function verifyControls(ctx, reload) {
100
+ const state = el("div", { class: "control-state" });
101
+ const offline = el("button", { class: "btn", type: "button", text: "Verify (offline)" });
102
+ offline.addEventListener("click", async () => {
103
+ offline.disabled = true;
104
+ const answer = await m3(api.channelVerify({ id: ctx.id }, { offline: true }));
105
+ offline.disabled = false;
106
+ if (!answer.ok) {
107
+ state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
108
+ return;
109
+ }
110
+ const failing = Array.isArray(answer.body.failing) ? answer.body.failing : [];
111
+ state.replaceChildren(
112
+ answer.body.ok === true
113
+ ? dot("ok", "every boot-gate check passes")
114
+ : dot("bad", `${failing.length} check(s) would stop the boot`),
115
+ ...failing.map((line) => el("div", { class: "muted reason", text: String(line) })),
116
+ el("div", { class: "muted reason", text: String(answer.body.note ?? "") }),
117
+ );
118
+ reload();
119
+ });
120
+ const live = el("button", { class: "btn btn-ghost", type: "button", text: "Verify (live)" });
121
+ live.title = "makes real platform calls — runs as a job";
122
+ live.addEventListener("click", async () => {
123
+ live.disabled = true;
124
+ const answer = await m3(api.channelVerify({ id: ctx.id }, { offline: false }));
125
+ live.disabled = false;
126
+ if (!answer.ok) {
127
+ state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
128
+ return;
129
+ }
130
+ state.replaceChildren(
131
+ dot("off", `queued as job ${String(answer.body.job?.jobId ?? "?")}`),
132
+ el("div", { class: "muted reason", text: String(answer.body.note ?? "") }),
133
+ );
134
+ });
135
+ return el("div", { class: "control-action" }, [
136
+ el("div", { class: "control-row" }, [offline, live]),
137
+ state,
138
+ ]);
139
+ }
140
+
141
+ function routingRow(routing) {
142
+ const model = routing && typeof routing === "object" ? routing : {};
143
+ // `sessionKeyMode`, not `sessionKey`: the server renames it on purpose so
144
+ // the value survives the response masker (see `routingView`).
145
+ const key =
146
+ model.sessionKeyMode === null || model.sessionKeyMode === undefined
147
+ ? "—"
148
+ : String(model.sessionKeyMode);
149
+ return el("div", { class: "kv" }, [
150
+ el("span", { class: "kv-k", text: String(model.specPath ?? "routing.sessionKey") }),
151
+ el("span", { class: "kv-v" }, [
152
+ el("code", { text: key }),
153
+ model.collectsReactions === true
154
+ ? dot("ok", "collects emoji reaction feedback")
155
+ : dot("warn", "collects NO emoji reaction feedback"),
156
+ el("div", { class: "muted reason", text: String(model.reactionsNote ?? "") }),
157
+ ]),
158
+ ]);
159
+ }
160
+
161
+ function channelCard(row, live, ctx, reload) {
162
+ const platform = String(row.platform);
163
+ const serving = live === null ? null : live.includes(platform);
164
+ const checks = Array.isArray(row.checks) ? row.checks : [];
165
+ const fields = Array.isArray(row.fields) ? row.fields : [];
166
+
167
+ const fieldList = el(
168
+ "ul",
169
+ { class: "check-list" },
170
+ fields.map((field) =>
171
+ el("li", { class: "check" }, [
172
+ field.set ? dot("ok", "set") : dot("bad", "unset"),
173
+ el("code", { text: String(field.field) }),
174
+ el("span", {
175
+ class: "muted",
176
+ text: field.envRef
177
+ ? ` ← $${String(field.envRef)}`
178
+ : field.inlineLiteral
179
+ ? " ← inline literal in the spec (prefer a $ENV ref)"
180
+ : " ← not declared",
181
+ }),
182
+ ]),
183
+ ),
184
+ );
185
+
186
+ const checkList = el(
187
+ "ul",
188
+ { class: "check-list" },
189
+ checks.map((check) =>
190
+ el("li", { class: "check" }, [
191
+ check.pass ? dot(check.informational ? "off" : "ok", "ok") : dot("bad", "blocking"),
192
+ el("span", { class: "pf-msg", text: String(check.label) }),
193
+ check.reason ? el("div", { class: "muted reason", text: String(check.reason) }) : null,
194
+ ]),
195
+ ),
196
+ );
197
+
198
+ return el("section", { class: "card" }, [
199
+ el("h3", { class: "card-title" }, [
200
+ el("span", { text: platform }),
201
+ row.bootGateOk ? dot("ok", "boot gate green") : dot("bad", "will not boot"),
202
+ serving === null
203
+ ? dot("off", "serving: unknown")
204
+ : serving
205
+ ? dot("ok", "serving")
206
+ : dot("warn", "not in the gateway's channel list"),
207
+ ]),
208
+ row.webhookPath
209
+ ? el("div", { class: "kv" }, [
210
+ el("span", { class: "kv-k", text: "webhook" }),
211
+ el("span", { class: "kv-v" }, [el("code", { text: String(row.webhookPath) })]),
212
+ ])
213
+ : null,
214
+ fieldList,
215
+ checkList,
216
+ tierControls(row, ctx, reload),
217
+ provisionFold(platform, ctx),
218
+ ]);
219
+ }
220
+
221
+ /** The two test tiers, each labelled with exactly what it proves, and each
222
+ * disabled WITH ITS REASON when the platform cannot support it. */
223
+ function tierControls(row, ctx, reload) {
224
+ const platform = String(row.platform);
225
+ const tier1 = row.tier1 && typeof row.tier1 === "object" ? row.tier1 : {};
226
+ const tier2 = row.tier2 && typeof row.tier2 === "object" ? row.tier2 : {};
227
+ const outcome = el("div", { class: "control-state" });
228
+
229
+ const probeBtn = el("button", {
230
+ class: "btn",
231
+ type: "button",
232
+ text: "Tier 1 · liveness",
233
+ title: String(tier1.proves ?? ""),
234
+ });
235
+ if (tier1.available !== true) probeBtn.disabled = true;
236
+ else {
237
+ probeBtn.addEventListener("click", async () => {
238
+ probeBtn.disabled = true;
239
+ const answer = await m3(api.channelProbe({ id: ctx.id, channel: platform }, {}));
240
+ probeBtn.disabled = false;
241
+ renderOutcome(outcome, answer);
242
+ });
243
+ }
244
+
245
+ const text = el("input", {
246
+ class: "input",
247
+ type: "text",
248
+ placeholder: "synthetic message text (optional)",
249
+ "aria-label": "synthetic message text",
250
+ });
251
+ const syntheticBtn = el("button", {
252
+ class: "btn btn-danger",
253
+ type: "button",
254
+ text: "Tier 2 · signed synthetic inbound",
255
+ title: String(tier2.proves ?? tier2.reason ?? ""),
256
+ });
257
+ if (tier2.available !== true) syntheticBtn.disabled = true;
258
+ else {
259
+ syntheticBtn.addEventListener("click", async () => {
260
+ syntheticBtn.disabled = true;
261
+ const answer = await m3(
262
+ api.channelSynthetic(
263
+ { id: ctx.id, channel: platform },
264
+ { confirm: true, ...(text.value !== "" ? { text: text.value } : {}) },
265
+ ),
266
+ );
267
+ syntheticBtn.disabled = false;
268
+ renderOutcome(outcome, answer);
269
+ if (answer.ok && answer.body.ok === true) reload();
270
+ });
271
+ }
272
+
273
+ return el("div", { class: "control-action" }, [
274
+ el("div", { class: "control-row" }, [probeBtn, syntheticBtn]),
275
+ el("p", { class: "muted", text: String(tier1.proves ?? "") }),
276
+ tier1.available === true
277
+ ? null
278
+ : el("p", { class: "muted gated-why", text: String(tier1.reason ?? "") }),
279
+ tier2.available === true
280
+ ? el("div", { class: "row-editor" }, [text])
281
+ : el("p", { class: "muted gated-why", text: String(tier2.reason ?? "") }),
282
+ outcome,
283
+ ]);
284
+ }
285
+
286
+ /** Every refusal this screen can receive is a FACT about the platform or the
287
+ * daemon (`expected: true`), so it renders as a state rather than a toast. */
288
+ function renderOutcome(node, answer) {
289
+ if (!answer.ok) {
290
+ node.replaceChildren(dot("bad", String(answer.body.error ?? `HTTP ${answer.status}`)));
291
+ return;
292
+ }
293
+ const body = answer.body;
294
+ const label =
295
+ body.ok === true
296
+ ? String(body.proves ?? "passed")
297
+ : String(body.reason ?? body.proves ?? "did not pass");
298
+ node.replaceChildren(
299
+ dot(body.ok === true ? "ok" : body.expected === true ? "off" : "warn", label),
300
+ typeof body.status === "number"
301
+ ? el("span", { class: "chip", text: `HTTP ${body.status}` })
302
+ : null,
303
+ typeof body.error === "string" && body.error !== ""
304
+ ? el("span", { class: "muted", text: body.error })
305
+ : null,
306
+ body.secretNote ? el("div", { class: "muted reason", text: String(body.secretNote) }) : null,
307
+ );
308
+ }
309
+
310
+ /** The provisioning PLAN, printed before anything is executed. */
311
+ function provisionFold(platform, ctx) {
312
+ const body = el("div", { class: "fold-body" }, [
313
+ el("span", { class: "muted", text: "loading…" }),
314
+ ]);
315
+ const fold = el("details", { class: "fold" }, [
316
+ el("summary", { class: "fold-summary" }, [
317
+ el("span", { class: "muted", text: `provisioning plan for ${platform}` }),
318
+ ]),
319
+ body,
320
+ ]);
321
+ let loaded = false;
322
+ fold.addEventListener("toggle", async () => {
323
+ if (!fold.open || loaded) return;
324
+ loaded = true;
325
+ const answer = await m3(api.channelProvision({ id: ctx.id, channel: platform }));
326
+ body.replaceChildren(...planNodes(answer, platform, ctx));
327
+ });
328
+ return fold;
329
+ }
330
+
331
+ function planNodes(answer, platform, ctx) {
332
+ if (!answer.ok) {
333
+ return [
334
+ el("p", { class: "muted", text: String(answer.body.error ?? `HTTP ${answer.status}`) }),
335
+ ];
336
+ }
337
+ const view = answer.body;
338
+ const plan = view.plan && typeof view.plan === "object" ? view.plan : {};
339
+ const steps = Array.isArray(plan.steps) ? plan.steps : [];
340
+ const nodes = [
341
+ el("div", { class: "rollup" }, [
342
+ dot(view.present === true ? "ok" : "off", String(plan.kind ?? "plan")),
343
+ el("span", { class: "chip", text: "print-only — nothing has been called" }),
344
+ ]),
345
+ view.present === true ? null : m3Empty(view, `No ${platform} block in this spec`),
346
+ typeof view.baseUrlNote === "string" && view.baseUrlNote !== ""
347
+ ? el("p", { class: "muted", text: view.baseUrlNote })
348
+ : null,
349
+ plan.callbackUrl
350
+ ? el("div", { class: "kv" }, [
351
+ el("span", { class: "kv-k", text: "callback" }),
352
+ el("span", { class: "kv-v" }, [el("code", { text: String(plan.callbackUrl) })]),
353
+ ])
354
+ : null,
355
+ el(
356
+ "ol",
357
+ { class: "check-list" },
358
+ steps.map((step) => el("li", { text: String(step) })),
359
+ ),
360
+ plan.note ? el("p", { class: "muted", text: String(plan.note) }) : null,
361
+ typeof plan.manifest === "string" ? numberedCode(plan.manifest, "slack app manifest") : null,
362
+ plan.request
363
+ ? collapsible(
364
+ [el("span", { class: "muted", text: "the request it makes" })],
365
+ [jsonPre(plan.request)],
366
+ )
367
+ : null,
368
+ ];
369
+
370
+ const baseUrl = el("input", {
371
+ class: "input",
372
+ type: "url",
373
+ placeholder: "https://your-public-host",
374
+ "aria-label": "public base URL",
375
+ });
376
+ const run = el("button", { class: "btn", type: "button", text: "Run provisioning" });
377
+ const state = el("div", { class: "control-state" });
378
+ run.addEventListener("click", async () => {
379
+ run.disabled = true;
380
+ const result = await m3(
381
+ api.channelProvisionRun(
382
+ { id: ctx.id, channel: platform },
383
+ { confirm: true, baseUrl: baseUrl.value },
384
+ ),
385
+ );
386
+ run.disabled = false;
387
+ if (!result.ok) {
388
+ toast(String(result.body.error ?? `HTTP ${result.status}`));
389
+ return;
390
+ }
391
+ state.replaceChildren(
392
+ result.body.submitted === true
393
+ ? dot("ok", "queued")
394
+ : dot("off", String(result.body.reason ?? "not submitted")),
395
+ );
396
+ });
397
+ nodes.push(el("div", { class: "row-editor" }, [baseUrl, run, state]));
398
+ return nodes.filter((node) => node !== null);
399
+ }
400
+
401
+ function gatewayCard(body) {
402
+ if (body.declared !== true) {
403
+ const add = body.addBlock && typeof body.addBlock === "object" ? body.addBlock : null;
404
+ const port = add?.value ? String(add.value.port) : "8787";
405
+ return [
406
+ m3Empty(body, "This spec declares no gateway: block"),
407
+ add
408
+ ? el("div", null, [
409
+ el("p", {
410
+ class: "muted",
411
+ text: "adding one gives the daemon a /status endpoint and, with ui: true, its own mini dashboard:",
412
+ }),
413
+ numberedCode(`gateway:\n port: ${port}\n ui: true\n`, "gateway block"),
414
+ el("p", { class: "muted", text: String(add.note ?? "") }),
415
+ ])
416
+ : null,
417
+ ];
418
+ }
419
+ const nodes = [
420
+ el("div", { class: "rollup" }, [
421
+ body.status === null || body.status === undefined
422
+ ? dot("off", "no status")
423
+ : dot(
424
+ "ok",
425
+ `turns ${String(body.turnCount ?? "—")} · heartbeats ${String(body.heartbeatCount ?? "—")}`,
426
+ ),
427
+ el("span", { class: "chip", text: `port ${String(body.port ?? "—")}` }),
428
+ body.ui === true ? el("span", { class: "chip", text: "ui enabled" }) : null,
429
+ typeof body.statusReason === "string" && body.statusReason !== ""
430
+ ? el("span", { class: "muted", text: body.statusReason })
431
+ : null,
432
+ ]),
433
+ ];
434
+ if (typeof body.dashboardUrl === "string") {
435
+ nodes.push(
436
+ el("p", null, [
437
+ el("a", {
438
+ href: body.dashboardUrl,
439
+ target: "_blank",
440
+ rel: "noreferrer",
441
+ text: "open the daemon's own dashboard",
442
+ }),
443
+ ]),
444
+ );
445
+ }
446
+ if (body.status !== null && body.status !== undefined) {
447
+ nodes.push(
448
+ collapsible(
449
+ [el("span", { class: "muted", text: "gateway /status" })],
450
+ [jsonPre(body.status)],
451
+ ),
452
+ );
453
+ }
454
+ return nodes;
455
+ }