@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.
- package/README.md +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- 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
|
+
}
|