@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,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HM-198's console half — Settings: notification rules (HM-183), read-only
|
|
3
|
+
* mode (HM-187), and the installed-plugin inventory (HM-179).
|
|
4
|
+
*
|
|
5
|
+
* Three deliberate choices show up here as UI:
|
|
6
|
+
*
|
|
7
|
+
* - Every rule ships OFF except parked approvals, and the screen says so,
|
|
8
|
+
* because a product that decides what deserves to interrupt you is a
|
|
9
|
+
* product you stop trusting.
|
|
10
|
+
* - The read-only toggle is labelled with what it actually protects. It
|
|
11
|
+
* prevents accidents during a demo; the bearer token is the security
|
|
12
|
+
* boundary. Claiming more would be a lie a screen-share could disprove.
|
|
13
|
+
* - The plugin table lists the DEFERRED extension points beside the wired
|
|
14
|
+
* ones, with their reasons. A capability that is declared but not wired
|
|
15
|
+
* has to be visible, or the next person wires it a second time.
|
|
16
|
+
*
|
|
17
|
+
* This screen does NOT read `GET /api/notifications` itself. That GET is the
|
|
18
|
+
* rules evaluation (HM-183), so a second caller consumes deliveries the app's
|
|
19
|
+
* poll would otherwise have toasted — the state comes from the one shared
|
|
20
|
+
* poll in `notify.js`, and the PUT's answer is folded back into it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { api } from "../api.js";
|
|
24
|
+
import { clear, dot, el, emptyState, skeleton, toast } from "../dom.js";
|
|
25
|
+
import { notifications } from "../notify.js";
|
|
26
|
+
import { hrefHarness } from "../router.js";
|
|
27
|
+
import { fmtRelativeTime } from "../util.js";
|
|
28
|
+
|
|
29
|
+
/** kind → the sentence the rule row shows. Mirrors the server's KIND_LABELS;
|
|
30
|
+
* an unknown kind falls back to its slug rather than disappearing. */
|
|
31
|
+
const KIND_LABELS = {
|
|
32
|
+
"approval-parked": "an approval is parked on a human",
|
|
33
|
+
"exit-30": "a run exited 30 (policy refusal)",
|
|
34
|
+
"exit-31": "a run exited 31 (provider funding)",
|
|
35
|
+
"exit-33": "a run exited 33 (unrecoverable config)",
|
|
36
|
+
"eval-gate-failed": "an eval gate failed against its baseline",
|
|
37
|
+
"incident-opened": "an incident was opened",
|
|
38
|
+
"dream-overdue": "a dream is overdue by more than two windows",
|
|
39
|
+
"budget-80": "spend passed 80% of the declared budget",
|
|
40
|
+
"crash-looping": "a daemon is crash-looping",
|
|
41
|
+
"credential-probe-failed": "a credential probe failed",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const SINKS = ["in-app", "os", "webhook"];
|
|
45
|
+
|
|
46
|
+
/** Why an event did not notify — the sentence beside the reason slug. */
|
|
47
|
+
const SUPPRESSED_REASONS = {
|
|
48
|
+
"rule-off": "the rule is off",
|
|
49
|
+
"group-muted": "the harness is in a muted group",
|
|
50
|
+
"quiet-hours": "quiet hours silenced every sink it had",
|
|
51
|
+
"already-delivered": "already notified this session",
|
|
52
|
+
"sink-unavailable": "no sink this manager can deliver on was selected",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export async function renderSettings(root) {
|
|
56
|
+
clear(root).appendChild(skeleton(6));
|
|
57
|
+
const [notifRes, readOnlyRes, pluginsRes, groupsRes] = await Promise.allSettled([
|
|
58
|
+
// The shared poll's state — never a second evaluating GET from here.
|
|
59
|
+
notifications.current(),
|
|
60
|
+
api.readOnly(),
|
|
61
|
+
api.plugins(),
|
|
62
|
+
api.groups(),
|
|
63
|
+
]);
|
|
64
|
+
clear(root);
|
|
65
|
+
root.appendChild(
|
|
66
|
+
el("div", { class: "rollup" }, [
|
|
67
|
+
el("h2", { text: "Settings" }),
|
|
68
|
+
el("span", { class: "muted", text: "manager preferences — never harness state" }),
|
|
69
|
+
]),
|
|
70
|
+
);
|
|
71
|
+
root.appendChild(readOnlyCard(settled(readOnlyRes)));
|
|
72
|
+
root.appendChild(
|
|
73
|
+
notificationCard(settled(notifRes), settled(groupsRes), () => renderSettings(root)),
|
|
74
|
+
);
|
|
75
|
+
root.appendChild(pluginCard(settled(pluginsRes)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function settled(res) {
|
|
79
|
+
return res.status === "fulfilled" ? res.value : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Read-only mode
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function readOnlyCard(state) {
|
|
87
|
+
const enabled = state?.enabled === true;
|
|
88
|
+
const locked = state?.locked === true;
|
|
89
|
+
const card = el("section", { class: "card" }, [
|
|
90
|
+
el("h3", { class: "card-title", text: "Read-only mode" }),
|
|
91
|
+
el("p", { class: "muted", text: String(state?.note ?? "") }),
|
|
92
|
+
el("div", { class: "add-form" }, [
|
|
93
|
+
dot(enabled ? "warn" : "ok", enabled ? "engaged — writes refused" : "off — writes allowed"),
|
|
94
|
+
locked ? el("span", { class: "chip chip-warn", text: "locked at startup" }) : null,
|
|
95
|
+
]),
|
|
96
|
+
]);
|
|
97
|
+
const button = el("button", {
|
|
98
|
+
class: enabled ? "btn" : "btn btn-danger",
|
|
99
|
+
type: "button",
|
|
100
|
+
text: enabled ? "Turn read-only off" : "Turn read-only on",
|
|
101
|
+
onClick: async () => {
|
|
102
|
+
button.disabled = true;
|
|
103
|
+
const res = await api.setReadOnly(!enabled);
|
|
104
|
+
button.disabled = false;
|
|
105
|
+
if (res.ok !== true) {
|
|
106
|
+
toast(
|
|
107
|
+
`${String(res.body?.message ?? "refused")} — ${String(res.body?.remedy ?? "")}`,
|
|
108
|
+
"error",
|
|
109
|
+
);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
window.location.reload();
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
if (locked && enabled) {
|
|
116
|
+
button.disabled = true;
|
|
117
|
+
button.title = "this manager was started with the mode locked — restart it without --read-only";
|
|
118
|
+
}
|
|
119
|
+
card.appendChild(button);
|
|
120
|
+
card.appendChild(
|
|
121
|
+
el("p", { class: "muted note" }, [
|
|
122
|
+
el("span", { text: "Exempt while engaged: " }),
|
|
123
|
+
el("code", {
|
|
124
|
+
class: "mono",
|
|
125
|
+
text: (Array.isArray(state?.exempt) ? state.exempt : []).join(", ") || "—",
|
|
126
|
+
}),
|
|
127
|
+
]),
|
|
128
|
+
);
|
|
129
|
+
return card;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Notification rules
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function notificationCard(state, groupsPayload, reload) {
|
|
137
|
+
const rules = Array.isArray(state?.rules) ? state.rules : [];
|
|
138
|
+
const card = el("section", { class: "card" }, [
|
|
139
|
+
el("h3", { class: "card-title", text: "Notifications" }),
|
|
140
|
+
el("p", {
|
|
141
|
+
class: "muted",
|
|
142
|
+
text: "Only parked approvals notify by default — everything else is visible on a screen you already open, so it waits for you to ask for it.",
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
if (rules.length === 0) {
|
|
146
|
+
card.appendChild(emptyState("No rules loaded", "crewhaus hangar serve"));
|
|
147
|
+
return card;
|
|
148
|
+
}
|
|
149
|
+
const groups = Array.isArray(groupsPayload?.groups)
|
|
150
|
+
? groupsPayload.groups.map((g) => String(g?.name ?? ""))
|
|
151
|
+
: [];
|
|
152
|
+
const draft = {
|
|
153
|
+
rules: rules.map((r) => ({
|
|
154
|
+
kind: String(r?.kind ?? ""),
|
|
155
|
+
enabled: r?.enabled === true,
|
|
156
|
+
sinks: Array.isArray(r?.sinks) ? r.sinks.map(String) : ["in-app"],
|
|
157
|
+
mutedGroups: Array.isArray(r?.mutedGroups) ? r.mutedGroups.map(String) : [],
|
|
158
|
+
})),
|
|
159
|
+
quietHours: {
|
|
160
|
+
enabled: state?.quietHours?.enabled === true,
|
|
161
|
+
startHour: Number(state?.quietHours?.startHour ?? 22),
|
|
162
|
+
endHour: Number(state?.quietHours?.endHour ?? 7),
|
|
163
|
+
utcOffsetMinutes: Number(state?.quietHours?.utcOffsetMinutes ?? 0),
|
|
164
|
+
},
|
|
165
|
+
mutedGroups: Array.isArray(state?.mutedGroups) ? state.mutedGroups.map(String) : [],
|
|
166
|
+
webhookUrl: typeof state?.webhookUrl === "string" ? state.webhookUrl : "",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const table = el("table", { class: "table rules-table" });
|
|
170
|
+
table.appendChild(
|
|
171
|
+
el("thead", null, [
|
|
172
|
+
el("tr", null, [
|
|
173
|
+
el("th", { text: "Notify when" }),
|
|
174
|
+
el("th", { text: "On" }),
|
|
175
|
+
...SINKS.map((s) => el("th", { text: s })),
|
|
176
|
+
]),
|
|
177
|
+
]),
|
|
178
|
+
);
|
|
179
|
+
const tbody = el("tbody");
|
|
180
|
+
for (const rule of draft.rules) {
|
|
181
|
+
const row = el("tr", null, [
|
|
182
|
+
el("td", null, [
|
|
183
|
+
el("span", { text: KIND_LABELS[rule.kind] ?? rule.kind }),
|
|
184
|
+
el("div", { class: "muted mono note", text: rule.kind }),
|
|
185
|
+
]),
|
|
186
|
+
el("td", null, [
|
|
187
|
+
checkbox(
|
|
188
|
+
rule.enabled,
|
|
189
|
+
(on) => {
|
|
190
|
+
rule.enabled = on;
|
|
191
|
+
},
|
|
192
|
+
`enable ${rule.kind}`,
|
|
193
|
+
),
|
|
194
|
+
]),
|
|
195
|
+
...SINKS.map((sink) =>
|
|
196
|
+
el("td", null, [
|
|
197
|
+
checkbox(
|
|
198
|
+
rule.sinks.includes(sink),
|
|
199
|
+
(on) => {
|
|
200
|
+
rule.sinks = on
|
|
201
|
+
? [...new Set([...rule.sinks, sink])]
|
|
202
|
+
: rule.sinks.filter((s) => s !== sink);
|
|
203
|
+
},
|
|
204
|
+
`${sink} for ${rule.kind}`,
|
|
205
|
+
),
|
|
206
|
+
]),
|
|
207
|
+
),
|
|
208
|
+
]);
|
|
209
|
+
tbody.appendChild(row);
|
|
210
|
+
}
|
|
211
|
+
table.appendChild(tbody);
|
|
212
|
+
card.appendChild(el("div", { class: "table-scroll" }, [table]));
|
|
213
|
+
|
|
214
|
+
// Quiet hours + muting + webhook.
|
|
215
|
+
const start = numberField(draft.quietHours.startHour, "quiet hours start (0–23)");
|
|
216
|
+
const end = numberField(draft.quietHours.endHour, "quiet hours end (0–23)");
|
|
217
|
+
const offset = numberField(draft.quietHours.utcOffsetMinutes, "UTC offset in minutes");
|
|
218
|
+
const quietOn = checkbox(
|
|
219
|
+
draft.quietHours.enabled,
|
|
220
|
+
(on) => {
|
|
221
|
+
draft.quietHours.enabled = on;
|
|
222
|
+
},
|
|
223
|
+
"quiet hours",
|
|
224
|
+
);
|
|
225
|
+
card.appendChild(
|
|
226
|
+
el("div", { class: "add-form" }, [
|
|
227
|
+
el("label", { class: "field-inline" }, [quietOn, el("span", { text: " Quiet hours" })]),
|
|
228
|
+
el("label", { class: "field-inline" }, [el("span", { text: "from " }), start]),
|
|
229
|
+
el("label", { class: "field-inline" }, [el("span", { text: "to " }), end]),
|
|
230
|
+
el("label", { class: "field-inline" }, [el("span", { text: "UTC offset (min) " }), offset]),
|
|
231
|
+
]),
|
|
232
|
+
);
|
|
233
|
+
card.appendChild(
|
|
234
|
+
el("p", {
|
|
235
|
+
class: "muted note",
|
|
236
|
+
text: "Quiet hours silence the OS toast and the webhook. The in-app badge stays — waking up to a console that says everything is fine would be worse than a number you can ignore.",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const mutedField = el("input", {
|
|
241
|
+
class: "input mono grow",
|
|
242
|
+
type: "text",
|
|
243
|
+
value: draft.mutedGroups.join(", "),
|
|
244
|
+
placeholder: groups.length > 0 ? groups.join(", ") : "group-a, group-b",
|
|
245
|
+
"aria-label": "muted groups",
|
|
246
|
+
});
|
|
247
|
+
const webhookField = el("input", {
|
|
248
|
+
class: "input mono grow",
|
|
249
|
+
type: "url",
|
|
250
|
+
value: draft.webhookUrl,
|
|
251
|
+
placeholder: "https://hooks.example.com/hangar",
|
|
252
|
+
"aria-label": "webhook URL",
|
|
253
|
+
});
|
|
254
|
+
card.appendChild(
|
|
255
|
+
el("div", { class: "add-form" }, [
|
|
256
|
+
el("label", { class: "field-inline" }, [el("span", { text: "Mute groups " }), mutedField]),
|
|
257
|
+
el("label", { class: "field-inline" }, [el("span", { text: "Webhook " }), webhookField]),
|
|
258
|
+
]),
|
|
259
|
+
);
|
|
260
|
+
card.appendChild(
|
|
261
|
+
el("p", {
|
|
262
|
+
class: "muted note",
|
|
263
|
+
text: "A webhook URL must not embed credentials — the manager refuses one that does rather than storing it and masking it later.",
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
card.appendChild(
|
|
267
|
+
el("div", { class: "add-form" }, [
|
|
268
|
+
el("button", {
|
|
269
|
+
class: "btn btn-primary",
|
|
270
|
+
type: "button",
|
|
271
|
+
text: "Save rules",
|
|
272
|
+
onClick: async (event) => {
|
|
273
|
+
const button = event.currentTarget;
|
|
274
|
+
button.disabled = true;
|
|
275
|
+
try {
|
|
276
|
+
const updated = await api.setNotifications({
|
|
277
|
+
rules: draft.rules,
|
|
278
|
+
quietHours: {
|
|
279
|
+
enabled: draft.quietHours.enabled,
|
|
280
|
+
startHour: Number(start.value),
|
|
281
|
+
endHour: Number(end.value),
|
|
282
|
+
utcOffsetMinutes: Number(offset.value),
|
|
283
|
+
},
|
|
284
|
+
mutedGroups: splitList(mutedField.value),
|
|
285
|
+
webhookUrl: webhookField.value.trim() === "" ? null : webhookField.value.trim(),
|
|
286
|
+
});
|
|
287
|
+
// The PUT answers with the same evaluated view the GET does —
|
|
288
|
+
// deliveries included. Fold it into the shared poll so nothing it
|
|
289
|
+
// just consumed goes unseen, and so the badge follows the save.
|
|
290
|
+
notifications.accept(updated);
|
|
291
|
+
toast("Notification rules saved", "info");
|
|
292
|
+
} catch (err) {
|
|
293
|
+
toast(err instanceof Error ? err.message : String(err), "error");
|
|
294
|
+
}
|
|
295
|
+
button.disabled = false;
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
el("button", {
|
|
299
|
+
class: "btn btn-ghost",
|
|
300
|
+
type: "button",
|
|
301
|
+
text: "Clear the badge",
|
|
302
|
+
onClick: async () => {
|
|
303
|
+
try {
|
|
304
|
+
await api.clearNotifications();
|
|
305
|
+
await notifications.refresh();
|
|
306
|
+
toast("Badge cleared", "info");
|
|
307
|
+
reload?.();
|
|
308
|
+
} catch (err) {
|
|
309
|
+
toast(err instanceof Error ? err.message : String(err), "error");
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
]),
|
|
314
|
+
);
|
|
315
|
+
card.appendChild(pendingList(state));
|
|
316
|
+
const why = suppressedList(state);
|
|
317
|
+
if (why !== null) card.appendChild(why);
|
|
318
|
+
return card;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* What the badge is COUNTING. A number an operator cannot open is a number
|
|
323
|
+
* they learn to ignore, and the in-app queue is the only record of a
|
|
324
|
+
* delivery there is — the dedupe set means the server will not report it a
|
|
325
|
+
* second time.
|
|
326
|
+
*/
|
|
327
|
+
function pendingList(state) {
|
|
328
|
+
const inApp = Array.isArray(state?.inApp) ? state.inApp : [];
|
|
329
|
+
const box = el("div", { class: "notif-queue" }, [
|
|
330
|
+
el("h4", { class: "sheet-section", text: "Waiting for you" }),
|
|
331
|
+
]);
|
|
332
|
+
if (inApp.length === 0) {
|
|
333
|
+
box.appendChild(el("p", { class: "muted note", text: "Nothing pending." }));
|
|
334
|
+
return box;
|
|
335
|
+
}
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
box.appendChild(
|
|
338
|
+
el(
|
|
339
|
+
"ul",
|
|
340
|
+
{ class: "check-list" },
|
|
341
|
+
inApp.map((d) => {
|
|
342
|
+
const id = String(d?.harnessId ?? "");
|
|
343
|
+
const label = String(d?.label ?? d?.kind ?? "");
|
|
344
|
+
return el("li", null, [
|
|
345
|
+
dot("warn", label),
|
|
346
|
+
el("span", { class: "muted", text: ` ${fmtRelativeTime(String(d?.at ?? ""), now)}` }),
|
|
347
|
+
id === ""
|
|
348
|
+
? null
|
|
349
|
+
: el("a", { class: "chip", href: hrefHarness(id, "overview"), text: "open →" }),
|
|
350
|
+
]);
|
|
351
|
+
}),
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
return box;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* …and what did NOT notify, with the reason. A silent rule has to be
|
|
359
|
+
* explainable from the screen, or the operator's only model of the feature
|
|
360
|
+
* is "it sometimes tells me things".
|
|
361
|
+
*/
|
|
362
|
+
function suppressedList(state) {
|
|
363
|
+
const suppressed = Array.isArray(state?.suppressed) ? state.suppressed : [];
|
|
364
|
+
if (suppressed.length === 0) return null;
|
|
365
|
+
const counts = new Map();
|
|
366
|
+
for (const s of suppressed) {
|
|
367
|
+
const reason = String(s?.reason ?? "unknown");
|
|
368
|
+
counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
|
369
|
+
}
|
|
370
|
+
return el("div", { class: "notif-suppressed" }, [
|
|
371
|
+
el("h4", { class: "sheet-section", text: "Seen, but not notified" }),
|
|
372
|
+
el(
|
|
373
|
+
"ul",
|
|
374
|
+
{ class: "check-list" },
|
|
375
|
+
[...counts.entries()].map(([reason, n]) =>
|
|
376
|
+
el("li", null, [
|
|
377
|
+
dot("off", `${n} × ${reason}`),
|
|
378
|
+
el("span", { class: "muted", text: ` — ${SUPPRESSED_REASONS[reason] ?? reason}` }),
|
|
379
|
+
]),
|
|
380
|
+
),
|
|
381
|
+
),
|
|
382
|
+
]);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function checkbox(checked, onChange, label) {
|
|
386
|
+
const node = el("input", { type: "checkbox", "aria-label": label });
|
|
387
|
+
node.checked = checked === true;
|
|
388
|
+
node.addEventListener("change", () => onChange(node.checked));
|
|
389
|
+
return node;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function numberField(value, label) {
|
|
393
|
+
const node = el("input", {
|
|
394
|
+
class: "input num-input",
|
|
395
|
+
type: "number",
|
|
396
|
+
value: String(value),
|
|
397
|
+
"aria-label": label,
|
|
398
|
+
});
|
|
399
|
+
return node;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** "a, b ,, c" → ["a","b","c"]. Exported for the unit tests. */
|
|
403
|
+
export function splitList(text) {
|
|
404
|
+
return String(text ?? "")
|
|
405
|
+
.split(",")
|
|
406
|
+
.map((s) => s.trim())
|
|
407
|
+
.filter((s) => s !== "");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// Plugins
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
function pluginCard(inventory) {
|
|
415
|
+
const plugins = Array.isArray(inventory?.plugins) ? inventory.plugins : [];
|
|
416
|
+
const card = el("section", { class: "card" }, [
|
|
417
|
+
el("h3", { class: "card-title", text: "Plugins" }),
|
|
418
|
+
el("p", { class: "muted" }, [
|
|
419
|
+
el("span", { text: "This build wires " }),
|
|
420
|
+
el("code", { class: "mono", text: (inventory?.wired ?? []).join(", ") || "nothing" }),
|
|
421
|
+
el("span", {
|
|
422
|
+
text: ". A plugin's pane runs in a sandboxed frame with an opaque origin and a policy built from its own net allow-list; a plugin that may not read a harness sees neither its panes nor its trace events.",
|
|
423
|
+
}),
|
|
424
|
+
]),
|
|
425
|
+
]);
|
|
426
|
+
const deferred =
|
|
427
|
+
inventory?.deferred && typeof inventory.deferred === "object"
|
|
428
|
+
? Object.entries(inventory.deferred)
|
|
429
|
+
: [];
|
|
430
|
+
if (deferred.length > 0) {
|
|
431
|
+
card.appendChild(
|
|
432
|
+
el("ul", { class: "check-list" }, [
|
|
433
|
+
el("li", { class: "muted", text: "Declared but NOT wired (deferred):" }),
|
|
434
|
+
...deferred.map(([point, reason]) =>
|
|
435
|
+
el("li", null, [
|
|
436
|
+
dot("off", point),
|
|
437
|
+
el("span", { class: "muted", text: ` — ${String(reason)}` }),
|
|
438
|
+
]),
|
|
439
|
+
),
|
|
440
|
+
]),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (plugins.length === 0) {
|
|
444
|
+
card.appendChild(emptyState("No plugins installed", "crewhaus plugins install <name>"));
|
|
445
|
+
return card;
|
|
446
|
+
}
|
|
447
|
+
card.appendChild(
|
|
448
|
+
el(
|
|
449
|
+
"ul",
|
|
450
|
+
{ class: "check-list" },
|
|
451
|
+
plugins.map((plugin) => pluginRow(plugin)),
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
return card;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function pluginRow(plugin) {
|
|
458
|
+
const problems = Array.isArray(plugin?.problems) ? plugin.problems : [];
|
|
459
|
+
const points = Array.isArray(plugin?.extensionPoints) ? plugin.extensionPoints : [];
|
|
460
|
+
const panes = Array.isArray(plugin?.panes) ? plugin.panes : [];
|
|
461
|
+
return el("li", { class: "plugin-row" }, [
|
|
462
|
+
el("div", null, [
|
|
463
|
+
el("strong", { text: String(plugin?.name ?? "") }),
|
|
464
|
+
el("span", { class: "muted mono", text: ` ${String(plugin?.version ?? "")}` }),
|
|
465
|
+
problems.length > 0 ? dot("bad", problems.join("; ")) : null,
|
|
466
|
+
]),
|
|
467
|
+
plugin?.description ? el("div", { class: "muted", text: String(plugin.description) }) : null,
|
|
468
|
+
el(
|
|
469
|
+
"div",
|
|
470
|
+
{ class: "safety-strip" },
|
|
471
|
+
points
|
|
472
|
+
.filter((p) => p.declared === true)
|
|
473
|
+
.map((p) =>
|
|
474
|
+
el("span", {
|
|
475
|
+
class: `chip${p.wired === true ? "" : " chip-warn"}`,
|
|
476
|
+
title: p.wired === true ? "wired" : String(p.reason ?? "not wired"),
|
|
477
|
+
text: `${p.point}: ${p.wired === true ? "wired" : "deferred"}`,
|
|
478
|
+
}),
|
|
479
|
+
),
|
|
480
|
+
),
|
|
481
|
+
panes.length > 0
|
|
482
|
+
? el("div", { class: "muted", text: `panes: ${panes.map((p) => p.id).join(", ")}` })
|
|
483
|
+
: null,
|
|
484
|
+
el("div", { class: "muted mono note" }, [
|
|
485
|
+
el("span", { text: `fs: ${(plugin?.permissions?.fs ?? []).join(" ") || "none"}` }),
|
|
486
|
+
el("span", { text: ` · net: ${(plugin?.permissions?.net ?? []).join(" ") || "none"}` }),
|
|
487
|
+
]),
|
|
488
|
+
]);
|
|
489
|
+
}
|