@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
package/assets/js/app.js
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hangar console entry. Boot order: token bootstrap (read `#t=<token>`
|
|
3
|
+
* from the URL fragment into sessionStorage and strip it via replaceState),
|
|
4
|
+
* theme, shell render, router start, version footer. Booting is guarded on
|
|
5
|
+
* `document` existing so the module can be imported by tests.
|
|
6
|
+
*
|
|
7
|
+
* M2 adds the fleet-wide nav (Runs, Approvals, Review, Activity) with live
|
|
8
|
+
* counts, and gives the harness header its supervision pill, bundle
|
|
9
|
+
* freshness badge, control availability and — when the spawn plan cannot be
|
|
10
|
+
* built — the remedy as a BUTTON rather than an error.
|
|
11
|
+
*
|
|
12
|
+
* M3 adds the detail tabs (Datasets, Feedback, Credentials, Channels,
|
|
13
|
+
* Security, Thredz, Inspect, Dev & MCP), stacks an M3 half beneath the
|
|
14
|
+
* existing Spec, Evals and Memory panels, and puts the fleet credential
|
|
15
|
+
* matrix, feedback rollup and Thredz explorer in the rail.
|
|
16
|
+
*
|
|
17
|
+
* The tab strip is where the shape × panel matrix is applied: the target
|
|
18
|
+
* shape decides which tabs are live and which render disabled with the
|
|
19
|
+
* reason (`tabAvailability` in router.js owns the table). Gating stops at
|
|
20
|
+
* the strip — a deep link to an off-matrix tab still resolves and the
|
|
21
|
+
* screen still answers, so no URL an operator saved goes dead.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { ApiError, api, onUnauthorized, setToken } from "./api.js";
|
|
25
|
+
import { clear, copyBtn, dot, el, skeleton, toast } from "./dom.js";
|
|
26
|
+
import { bindingSheet, globalKey, initialKeyState, keyShape } from "./keys.js";
|
|
27
|
+
import { notifications } from "./notify.js";
|
|
28
|
+
import { openOmnibox } from "./omnibox.js";
|
|
29
|
+
import {
|
|
30
|
+
hrefGlobal,
|
|
31
|
+
hrefHarness,
|
|
32
|
+
hrefLibrary,
|
|
33
|
+
parseRoute,
|
|
34
|
+
startRouter,
|
|
35
|
+
tabAvailability,
|
|
36
|
+
} from "./router.js";
|
|
37
|
+
import { shapeAccent, shapeLabel } from "./shapes.js";
|
|
38
|
+
import { procRow } from "./supervision.js";
|
|
39
|
+
import { renderActivity } from "./views/activity.js";
|
|
40
|
+
import { renderApprovals } from "./views/approvals.js";
|
|
41
|
+
import { renderChannels } from "./views/channels.js";
|
|
42
|
+
import { renderCosts } from "./views/costs.js";
|
|
43
|
+
import { renderCredentialsMatrix, renderCreds } from "./views/creds.js";
|
|
44
|
+
import { renderData } from "./views/data.js";
|
|
45
|
+
import { renderDeploy } from "./views/deploy.js";
|
|
46
|
+
import { renderEvalsLab } from "./views/evals-lab.js";
|
|
47
|
+
import { renderEvals } from "./views/evals.js";
|
|
48
|
+
import { renderFeedback, renderFeedbackBoard } from "./views/feedback.js";
|
|
49
|
+
import { renderHealthBoard, renderHealthCard } from "./views/health.js";
|
|
50
|
+
import { renderInspect } from "./views/inspect.js";
|
|
51
|
+
import { renderLibrary } from "./views/library.js";
|
|
52
|
+
import { renderMemoryFabric } from "./views/memory-fabric.js";
|
|
53
|
+
import { renderMemory } from "./views/memory.js";
|
|
54
|
+
import { renderOverview } from "./views/overview.js";
|
|
55
|
+
import { renderPanes } from "./views/panes.js";
|
|
56
|
+
import { renderReview } from "./views/review.js";
|
|
57
|
+
import { renderRuns, renderRunsBoard } from "./views/runs.js";
|
|
58
|
+
import { renderRuntime } from "./views/runtime.js";
|
|
59
|
+
import { renderSchedulers } from "./views/schedulers.js";
|
|
60
|
+
import { renderSecurity } from "./views/security.js";
|
|
61
|
+
import { renderSessions } from "./views/sessions.js";
|
|
62
|
+
import { renderSettings } from "./views/settings.js";
|
|
63
|
+
import { renderSpecEdit } from "./views/spec-edit.js";
|
|
64
|
+
import { renderSpec } from "./views/spec.js";
|
|
65
|
+
import { renderThredz, renderThredzGlobal } from "./views/thredz.js";
|
|
66
|
+
import { renderTokenScreen } from "./views/token.js";
|
|
67
|
+
|
|
68
|
+
const THEME_KEY = "hangar.theme";
|
|
69
|
+
const TAB_LABELS = {
|
|
70
|
+
overview: "Overview",
|
|
71
|
+
spec: "Spec",
|
|
72
|
+
runs: "Runs",
|
|
73
|
+
schedulers: "Schedulers",
|
|
74
|
+
sessions: "Sessions",
|
|
75
|
+
evals: "Evals",
|
|
76
|
+
memory: "Memory",
|
|
77
|
+
data: "Datasets",
|
|
78
|
+
feedback: "Feedback",
|
|
79
|
+
costs: "Costs",
|
|
80
|
+
creds: "Credentials",
|
|
81
|
+
channels: "Channels",
|
|
82
|
+
security: "Security",
|
|
83
|
+
thredz: "Thredz",
|
|
84
|
+
deploy: "Deployments",
|
|
85
|
+
inspect: "Inspect",
|
|
86
|
+
dev: "Dev & MCP",
|
|
87
|
+
panes: "Plugin panes",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The fleet-wide screens in the header rail, with their count sources. */
|
|
91
|
+
const NAV = [
|
|
92
|
+
{ view: "runs", label: "Runs & daemons" },
|
|
93
|
+
{ view: "approvals", label: "Approvals" },
|
|
94
|
+
{ view: "review", label: "Review" },
|
|
95
|
+
{ view: "activity", label: "Activity" },
|
|
96
|
+
{ view: "health", label: "Health" },
|
|
97
|
+
{ view: "credentials", label: "Credentials" },
|
|
98
|
+
{ view: "feedback", label: "Feedback" },
|
|
99
|
+
{ view: "thredz", label: "Thredz" },
|
|
100
|
+
{ view: "settings", label: "Settings" },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
let viewRoot = null;
|
|
104
|
+
let footRoot = null;
|
|
105
|
+
let navRoot = null;
|
|
106
|
+
let bannerRoot = null;
|
|
107
|
+
let dispatchSeq = 0;
|
|
108
|
+
let navCounts = {};
|
|
109
|
+
// M4 (HM-190): overlay ownership. `inboxMounted` is set per navigation so
|
|
110
|
+
// `?` goes to the inbox's own sheet on the two screens that bind it, and to
|
|
111
|
+
// the app's sheet everywhere else.
|
|
112
|
+
let keyState = initialKeyState();
|
|
113
|
+
let omnibox = null;
|
|
114
|
+
let sheetNode = null;
|
|
115
|
+
let notificationsStarted = false;
|
|
116
|
+
|
|
117
|
+
/** Move a `#t=<token>` fragment into sessionStorage and off the URL. */
|
|
118
|
+
function bootstrapToken() {
|
|
119
|
+
const m = window.location.hash.match(/^#t=(.+)$/);
|
|
120
|
+
if (m === null) return;
|
|
121
|
+
let token = m[1];
|
|
122
|
+
try {
|
|
123
|
+
token = decodeURIComponent(token);
|
|
124
|
+
} catch {
|
|
125
|
+
// keep the raw fragment
|
|
126
|
+
}
|
|
127
|
+
setToken(token);
|
|
128
|
+
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#/`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function applyTheme(theme) {
|
|
132
|
+
document.documentElement.dataset.theme = theme === "light" ? "light" : "dark";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function initTheme() {
|
|
136
|
+
let stored = null;
|
|
137
|
+
try {
|
|
138
|
+
stored = localStorage.getItem(THEME_KEY);
|
|
139
|
+
} catch {
|
|
140
|
+
// storage unavailable — default applies
|
|
141
|
+
}
|
|
142
|
+
applyTheme(stored === "light" ? "light" : "dark");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toggleTheme() {
|
|
146
|
+
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
|
|
147
|
+
applyTheme(next);
|
|
148
|
+
try {
|
|
149
|
+
localStorage.setItem(THEME_KEY, next);
|
|
150
|
+
} catch {
|
|
151
|
+
// fine — the toggle just won't persist
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderShell(appRoot) {
|
|
156
|
+
clear(appRoot);
|
|
157
|
+
const themeBtn = el("button", {
|
|
158
|
+
class: "btn btn-ghost",
|
|
159
|
+
type: "button",
|
|
160
|
+
text: "Theme",
|
|
161
|
+
title: "Toggle light/dark",
|
|
162
|
+
onClick: toggleTheme,
|
|
163
|
+
});
|
|
164
|
+
navRoot = el("nav", { class: "app-nav", "aria-label": "fleet screens" });
|
|
165
|
+
// A visible affordance for ⌘K: a keyboard-only feature is a feature only
|
|
166
|
+
// the person who built it knows about.
|
|
167
|
+
const searchBtn = el("button", {
|
|
168
|
+
class: "btn btn-ghost omni-open",
|
|
169
|
+
type: "button",
|
|
170
|
+
title: "Search the fleet (⌘K)",
|
|
171
|
+
onClick: () => openOverlay(),
|
|
172
|
+
});
|
|
173
|
+
searchBtn.appendChild(el("span", { text: "Search" }));
|
|
174
|
+
searchBtn.appendChild(el("kbd", { class: "mono", text: "⌘K" }));
|
|
175
|
+
const header = el("header", { class: "app-header" }, [
|
|
176
|
+
el("a", { class: "brand", href: hrefLibrary() }, [
|
|
177
|
+
el("span", { class: "logo", "aria-hidden": "true", text: "H" }),
|
|
178
|
+
el("span", { class: "brand-name", text: "Hangar" }),
|
|
179
|
+
el("span", { class: "brand-sub", text: "CrewHaus harness manager" }),
|
|
180
|
+
]),
|
|
181
|
+
navRoot,
|
|
182
|
+
el("span", { class: "spacer" }),
|
|
183
|
+
searchBtn,
|
|
184
|
+
themeBtn,
|
|
185
|
+
]);
|
|
186
|
+
bannerRoot = el("div", { class: "banner-slot", id: "banners" });
|
|
187
|
+
viewRoot = el("main", { class: "view", id: "view" });
|
|
188
|
+
footRoot = el("footer", { class: "app-foot", id: "foot" }, [
|
|
189
|
+
el("span", { class: "muted", text: "every action shows the CLI command it runs" }),
|
|
190
|
+
]);
|
|
191
|
+
appRoot.appendChild(header);
|
|
192
|
+
appRoot.appendChild(bannerRoot);
|
|
193
|
+
appRoot.appendChild(viewRoot);
|
|
194
|
+
appRoot.appendChild(footRoot);
|
|
195
|
+
drawNav(parseRoute(window.location.hash));
|
|
196
|
+
loadVersion();
|
|
197
|
+
loadNavCounts();
|
|
198
|
+
startNotifications();
|
|
199
|
+
loadReadOnlyBanner();
|
|
200
|
+
installKeyboard();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* HM-187 — the read-only banner. The mode is enforced SERVER-side; this
|
|
205
|
+
* banner exists so an operator is not left guessing why a button just
|
|
206
|
+
* answered 403. It states the mode, and it links to the toggle.
|
|
207
|
+
*/
|
|
208
|
+
async function loadReadOnlyBanner() {
|
|
209
|
+
if (bannerRoot === null) return;
|
|
210
|
+
let state = null;
|
|
211
|
+
try {
|
|
212
|
+
state = await api.readOnly();
|
|
213
|
+
} catch {
|
|
214
|
+
return; // an old server without the route: no banner, no error
|
|
215
|
+
}
|
|
216
|
+
clear(bannerRoot);
|
|
217
|
+
if (state?.enabled !== true) return;
|
|
218
|
+
bannerRoot.appendChild(
|
|
219
|
+
el("div", { class: "banner banner-warn", role: "status" }, [
|
|
220
|
+
dot("warn", "read-only mode"),
|
|
221
|
+
el("span", {
|
|
222
|
+
text: "every mutating request is refused by the server — this manager is in demo/screen-share mode.",
|
|
223
|
+
}),
|
|
224
|
+
el("a", { class: "banner-link", href: hrefGlobal("settings"), text: "Settings →" }),
|
|
225
|
+
state.locked === true
|
|
226
|
+
? el("span", { class: "chip chip-warn", text: "locked at startup" })
|
|
227
|
+
: null,
|
|
228
|
+
]),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The nav, with the active screen marked. Counts arrive separately. */
|
|
233
|
+
function drawNav(route, counts = {}) {
|
|
234
|
+
if (navRoot === null) return;
|
|
235
|
+
clear(navRoot);
|
|
236
|
+
navRoot.appendChild(
|
|
237
|
+
el("a", {
|
|
238
|
+
class: `navlink${route.view === "library" ? " active" : ""}`,
|
|
239
|
+
href: hrefLibrary(),
|
|
240
|
+
text: "Library",
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
for (const item of NAV) {
|
|
244
|
+
const count = counts[item.view];
|
|
245
|
+
const link = el("a", {
|
|
246
|
+
class: `navlink${route.view === item.view ? " active" : ""}`,
|
|
247
|
+
href: hrefGlobal(item.view),
|
|
248
|
+
});
|
|
249
|
+
link.appendChild(el("span", { text: item.label }));
|
|
250
|
+
if (typeof count === "number" && count > 0) {
|
|
251
|
+
link.appendChild(el("span", { class: "nav-badge", text: String(count) }));
|
|
252
|
+
}
|
|
253
|
+
navRoot.appendChild(link);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Merge counts into the nav and redraw. The three numbers arrive from two
|
|
258
|
+
* sources — the inbox reads here, the notification badge from the shared
|
|
259
|
+
* poll — so they are merged rather than replaced, and neither can wipe the
|
|
260
|
+
* other's number on the way past. */
|
|
261
|
+
function setNavCounts(patch) {
|
|
262
|
+
navCounts = { ...navCounts, ...patch };
|
|
263
|
+
drawNav(parseRoute(window.location.hash), navCounts);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Pending approvals and open review items — the two inbox numbers. The
|
|
267
|
+
* notification badge is the shared poll's (see {@link startNotifications}). */
|
|
268
|
+
async function loadNavCounts() {
|
|
269
|
+
const [approvals, review] = await Promise.allSettled([api.approvals(), api.review()]);
|
|
270
|
+
const counts = {};
|
|
271
|
+
if (approvals.status === "fulfilled" && approvals.value && typeof approvals.value === "object") {
|
|
272
|
+
counts.approvals = approvals.value.pending;
|
|
273
|
+
}
|
|
274
|
+
if (review.status === "fulfilled" && review.value && typeof review.value === "object") {
|
|
275
|
+
counts.review = review.value.open;
|
|
276
|
+
}
|
|
277
|
+
setNavCounts(counts);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* HM-183 — the notification loop.
|
|
282
|
+
*
|
|
283
|
+
* The manager runs no timer of its own: `GET /api/notifications` IS the
|
|
284
|
+
* rules evaluation, so a console that asks only at boot never notifies
|
|
285
|
+
* anybody however long it is left open — which is precisely the case the
|
|
286
|
+
* one default-on rule (a parked approval) exists for. The console asks on
|
|
287
|
+
* an interval instead, through the single shared poll in `notify.js`, so
|
|
288
|
+
* that no OTHER screen can consume a delivery this listener would have
|
|
289
|
+
* toasted. Every snapshot the poll sees lands here: the badge follows it,
|
|
290
|
+
* and anything delivered on that pass is said out loud.
|
|
291
|
+
*/
|
|
292
|
+
function startNotifications() {
|
|
293
|
+
if (notificationsStarted) return;
|
|
294
|
+
notificationsStarted = true;
|
|
295
|
+
notifications.subscribe((payload, delivered) => {
|
|
296
|
+
setNavCounts({ settings: Number(payload?.badge ?? 0) });
|
|
297
|
+
if (delivered.length === 1) toast(String(delivered[0]?.label ?? "notification"), "info");
|
|
298
|
+
else if (delivered.length > 1) toast(`${delivered.length} new notifications`, "info");
|
|
299
|
+
});
|
|
300
|
+
notifications.start();
|
|
301
|
+
// A hidden tab is nobody's console: stop the fleet-wide scan each poll
|
|
302
|
+
// costs the server, and take a fresh reading the moment it comes back.
|
|
303
|
+
document.addEventListener("visibilitychange", () => {
|
|
304
|
+
if (document.hidden === true) notifications.stop();
|
|
305
|
+
else notifications.start();
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function loadVersion() {
|
|
310
|
+
for (const old of footRoot.querySelectorAll(".foot-version")) old.remove();
|
|
311
|
+
let node;
|
|
312
|
+
try {
|
|
313
|
+
const v = await api.version();
|
|
314
|
+
if (!v || typeof v !== "object") return;
|
|
315
|
+
// The version route reports { hangar, protocolV }.
|
|
316
|
+
node = el("span", {
|
|
317
|
+
class: "muted mono foot-version",
|
|
318
|
+
text: `hangar ${String(v.hangar ?? "?")} · protocol v${String(v.protocolV ?? "?")}`,
|
|
319
|
+
});
|
|
320
|
+
} catch {
|
|
321
|
+
node = el("span", { class: "muted foot-version", text: "version unavailable" });
|
|
322
|
+
}
|
|
323
|
+
footRoot.appendChild(node);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function dispatch(route) {
|
|
327
|
+
const seq = ++dispatchSeq;
|
|
328
|
+
drawNav(route, navCounts);
|
|
329
|
+
// The two screens that bind `?` for their own inbox sheet. Tracked here
|
|
330
|
+
// rather than sniffed from the DOM so the ownership rule stays a pure
|
|
331
|
+
// input to the `keys.js` reducer.
|
|
332
|
+
keyState = { ...keyState, inboxMounted: route.view === "approvals" || route.view === "review" };
|
|
333
|
+
clear(viewRoot).appendChild(skeleton(6));
|
|
334
|
+
try {
|
|
335
|
+
if (route.view === "library") {
|
|
336
|
+
await renderLibrary(viewRoot);
|
|
337
|
+
} else if (route.view === "harness") {
|
|
338
|
+
await renderHarnessPage(viewRoot, route);
|
|
339
|
+
} else if (route.view === "runs") {
|
|
340
|
+
await renderRunsBoard(viewRoot);
|
|
341
|
+
} else if (route.view === "approvals") {
|
|
342
|
+
await renderApprovals(viewRoot);
|
|
343
|
+
} else if (route.view === "review") {
|
|
344
|
+
await renderReview(viewRoot);
|
|
345
|
+
} else if (route.view === "activity") {
|
|
346
|
+
await renderActivity(viewRoot);
|
|
347
|
+
} else if (route.view === "credentials") {
|
|
348
|
+
await renderCredentialsMatrix(viewRoot);
|
|
349
|
+
} else if (route.view === "feedback") {
|
|
350
|
+
await renderFeedbackBoard(viewRoot);
|
|
351
|
+
} else if (route.view === "thredz") {
|
|
352
|
+
await renderThredzGlobal(viewRoot);
|
|
353
|
+
} else if (route.view === "health") {
|
|
354
|
+
await renderHealthBoard(viewRoot);
|
|
355
|
+
} else if (route.view === "settings") {
|
|
356
|
+
await renderSettings(viewRoot);
|
|
357
|
+
} else {
|
|
358
|
+
renderNotFound(viewRoot, route);
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
if (seq !== dispatchSeq) return; // superseded by a newer navigation
|
|
362
|
+
if (err instanceof ApiError && err.status === 401) return; // token screen owns the view
|
|
363
|
+
renderError(viewRoot, err);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function renderHarnessPage(root, route) {
|
|
368
|
+
// The process picture rides along with the detail so the header can show
|
|
369
|
+
// supervision without a second paint; a failure leaves the M1 header.
|
|
370
|
+
const [detailRes, procRes] = await Promise.allSettled([
|
|
371
|
+
api.harness(route.id),
|
|
372
|
+
api.proc(route.id),
|
|
373
|
+
]);
|
|
374
|
+
if (detailRes.status === "rejected") throw detailRes.reason;
|
|
375
|
+
const detail = detailRes.value;
|
|
376
|
+
const proc = procRes.status === "fulfilled" ? procRes.value : null;
|
|
377
|
+
clear(root);
|
|
378
|
+
if (detail === null) {
|
|
379
|
+
root.appendChild(
|
|
380
|
+
el("div", { class: "card" }, [
|
|
381
|
+
el("h2", { text: "Not registered" }),
|
|
382
|
+
el("p", { class: "muted", text: "No harness with that id is in the registry." }),
|
|
383
|
+
el("a", { href: hrefLibrary(), text: "← back to Library" }),
|
|
384
|
+
]),
|
|
385
|
+
);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const entry = detail.entry && typeof detail.entry === "object" ? detail.entry : {};
|
|
389
|
+
const ctx = {
|
|
390
|
+
id: route.id,
|
|
391
|
+
detail,
|
|
392
|
+
route,
|
|
393
|
+
proc,
|
|
394
|
+
dir: typeof entry.dir === "string" ? entry.dir : "",
|
|
395
|
+
reload: () => renderHarnessPage(root, route),
|
|
396
|
+
};
|
|
397
|
+
root.appendChild(harnessHeader(detail, route, ctx));
|
|
398
|
+
// The shape × panel matrix, applied where the strip is drawn: a tab this
|
|
399
|
+
// target's schema cannot declare renders as a disabled label carrying the
|
|
400
|
+
// reason, not as a link into a screen whose only content is "not
|
|
401
|
+
// applicable". The deep link still resolves, so a shared URL still opens.
|
|
402
|
+
const availability = tabAvailability(targetOf(detail));
|
|
403
|
+
root.appendChild(
|
|
404
|
+
el(
|
|
405
|
+
"nav",
|
|
406
|
+
{ class: "tabs", "aria-label": "harness tabs" },
|
|
407
|
+
availability.map((row) => tabNode(route, row)),
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
const current = availability.find((row) => row.tab === route.tab);
|
|
411
|
+
if (current !== undefined && !current.on) {
|
|
412
|
+
root.appendChild(
|
|
413
|
+
el("div", { class: "rollup" }, [
|
|
414
|
+
dot("off", "off this shape's panel matrix"),
|
|
415
|
+
el("span", { class: "muted", text: current.reason }),
|
|
416
|
+
]),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
const tabRoot = el("div", { class: "tab-body" });
|
|
420
|
+
root.appendChild(tabRoot);
|
|
421
|
+
// The three tabs that gained an M3 half render the M1/M2 surface first and
|
|
422
|
+
// the new one beneath it — one screen, not two competing ones.
|
|
423
|
+
if (route.tab === "spec") {
|
|
424
|
+
await renderSpec(tabRoot, ctx);
|
|
425
|
+
await renderSpecEdit(section(tabRoot), ctx);
|
|
426
|
+
} else if (route.tab === "runs") await renderRuns(tabRoot, ctx);
|
|
427
|
+
else if (route.tab === "schedulers") await renderSchedulers(tabRoot, ctx);
|
|
428
|
+
else if (route.tab === "sessions") await renderSessions(tabRoot, ctx);
|
|
429
|
+
else if (route.tab === "evals") {
|
|
430
|
+
await renderEvals(tabRoot, ctx);
|
|
431
|
+
await renderEvalsLab(section(tabRoot), ctx);
|
|
432
|
+
} else if (route.tab === "memory") {
|
|
433
|
+
await renderMemory(tabRoot, ctx);
|
|
434
|
+
await renderMemoryFabric(section(tabRoot), ctx);
|
|
435
|
+
} else if (route.tab === "data") await renderData(tabRoot, ctx);
|
|
436
|
+
else if (route.tab === "feedback") await renderFeedback(tabRoot, ctx);
|
|
437
|
+
else if (route.tab === "costs") await renderCosts(tabRoot, ctx);
|
|
438
|
+
else if (route.tab === "creds") await renderCreds(tabRoot, ctx);
|
|
439
|
+
else if (route.tab === "channels") await renderChannels(tabRoot, ctx);
|
|
440
|
+
else if (route.tab === "security") await renderSecurity(tabRoot, ctx);
|
|
441
|
+
else if (route.tab === "thredz") await renderThredz(tabRoot, ctx);
|
|
442
|
+
else if (route.tab === "deploy") await renderDeploy(tabRoot, ctx);
|
|
443
|
+
else if (route.tab === "inspect") await renderInspect(tabRoot, ctx);
|
|
444
|
+
else if (route.tab === "dev") await renderRuntime(tabRoot, ctx);
|
|
445
|
+
else if (route.tab === "panes") await renderPanes(tabRoot, ctx);
|
|
446
|
+
else {
|
|
447
|
+
await renderOverview(tabRoot, ctx);
|
|
448
|
+
// The explained score (HM-11) loads on its own: it runs a preflight, and
|
|
449
|
+
// the Overview's first paint must not wait for one.
|
|
450
|
+
await renderHealthCard(section(tabRoot), ctx);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* One tab in the strip. An on-matrix tab is a link; an off-matrix one is a
|
|
456
|
+
* disabled label whose accessible name carries the reason as TEXT — the
|
|
457
|
+
* dimming is never the whole message.
|
|
458
|
+
*/
|
|
459
|
+
function tabNode(route, row) {
|
|
460
|
+
const label = TAB_LABELS[row.tab] ?? row.tab;
|
|
461
|
+
const active = route.tab === row.tab;
|
|
462
|
+
if (row.on) {
|
|
463
|
+
return el("a", {
|
|
464
|
+
class: `tab${active ? " active" : ""}`,
|
|
465
|
+
"aria-current": active ? "page" : null,
|
|
466
|
+
href: hrefHarness(route.id, row.tab),
|
|
467
|
+
text: label,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return el("span", {
|
|
471
|
+
class: `tab tab-off muted${active ? " active" : ""}`,
|
|
472
|
+
// The console ships one stylesheet and this state is the only new one on
|
|
473
|
+
// the strip, so it dims inline rather than growing the CSS a rule that
|
|
474
|
+
// one span uses. The reason still travels as text, in both the tooltip
|
|
475
|
+
// and the accessible name — the dimming is a hint, never the message.
|
|
476
|
+
style: { opacity: "0.45", cursor: "not-allowed" },
|
|
477
|
+
"aria-disabled": "true",
|
|
478
|
+
"aria-current": active ? "page" : null,
|
|
479
|
+
"aria-label": `${label} — not part of this shape: ${row.reason}`,
|
|
480
|
+
title: row.reason,
|
|
481
|
+
text: label,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The target shape string, read from the same places the header reads it
|
|
486
|
+
* (the inventory's parsed header first, the registry row as the fallback).
|
|
487
|
+
* One reader, so the strip and the badge can never disagree. */
|
|
488
|
+
function targetOf(detail) {
|
|
489
|
+
const entry = detail.entry && typeof detail.entry === "object" ? detail.entry : {};
|
|
490
|
+
const inv = detail.inventory && typeof detail.inventory === "object" ? detail.inventory : {};
|
|
491
|
+
const header = inv.header && typeof inv.header === "object" ? inv.header : {};
|
|
492
|
+
const explicit = header.target ?? entry.target;
|
|
493
|
+
return typeof explicit === "string" ? explicit : "";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Append a fresh sub-container so a second render into the same tab does
|
|
497
|
+
* not clear the first (every view clears the root it is handed). */
|
|
498
|
+
function section(root) {
|
|
499
|
+
const node = el("div", { class: "tab-section" });
|
|
500
|
+
root.appendChild(node);
|
|
501
|
+
return node;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Detail header. The detail payload nests all identity fields under
|
|
506
|
+
* `entry` (the registry row) with richer names on `inventory`; the header
|
|
507
|
+
* reads those, never invented top-level fields. Capability chips come from
|
|
508
|
+
* the server's `badges` (the lenient spec scan) — the M1 posture line — and
|
|
509
|
+
* M2 adds the supervision strip beneath it.
|
|
510
|
+
*/
|
|
511
|
+
function harnessHeader(detail, route, ctx) {
|
|
512
|
+
const entry = detail.entry && typeof detail.entry === "object" ? detail.entry : {};
|
|
513
|
+
const inv = detail.inventory && typeof detail.inventory === "object" ? detail.inventory : {};
|
|
514
|
+
const name = String(inv.specName ?? entry.specName ?? route.id);
|
|
515
|
+
const target = targetOf(detail);
|
|
516
|
+
const dir = String(entry.dir ?? "");
|
|
517
|
+
const missing = detail.missing === true || typeof entry.missingSince === "string";
|
|
518
|
+
const head = el("div", { class: "h-head", style: { "--accent": shapeAccent(target) } }, [
|
|
519
|
+
el("div", { class: "h-title" }, [
|
|
520
|
+
el("span", { class: "shape-badge", text: shapeLabel(target) }),
|
|
521
|
+
el("h2", { text: name }),
|
|
522
|
+
missing ? dot("bad", "directory missing") : null,
|
|
523
|
+
]),
|
|
524
|
+
dir !== ""
|
|
525
|
+
? el("div", { class: "h-dir" }, [
|
|
526
|
+
el("span", { class: "mono muted", title: dir, text: dir }),
|
|
527
|
+
copyBtn(dir, "copy"),
|
|
528
|
+
])
|
|
529
|
+
: null,
|
|
530
|
+
badgeStrip(detail.badges),
|
|
531
|
+
supervisionStrip(ctx),
|
|
532
|
+
]);
|
|
533
|
+
return head;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Supervision pill + bundle freshness + control availability + remedy. */
|
|
537
|
+
function supervisionStrip(ctx) {
|
|
538
|
+
if (ctx.proc === null) return null;
|
|
539
|
+
const row = procRow(ctx.proc, Date.now());
|
|
540
|
+
const strip = el("div", { class: "sup-strip" }, [
|
|
541
|
+
dot(row.pill.dot, row.pill.label),
|
|
542
|
+
row.adopted ? el("span", { class: "chip", text: "adopted" }) : null,
|
|
543
|
+
row.draining ? el("span", { class: "chip chip-warn", text: "draining" }) : null,
|
|
544
|
+
row.runId !== null
|
|
545
|
+
? el("a", {
|
|
546
|
+
class: "chip",
|
|
547
|
+
href: hrefHarness(ctx.id, "runs", row.runId),
|
|
548
|
+
text: "watch this run",
|
|
549
|
+
})
|
|
550
|
+
: null,
|
|
551
|
+
// The exact/approximate distinction is the badge's whole point — but
|
|
552
|
+
// only where there IS a verdict to qualify.
|
|
553
|
+
el("span", {
|
|
554
|
+
class: `chip${row.bundle.dot === "warn" ? " chip-warn" : ""}`,
|
|
555
|
+
title: row.bundle.precision,
|
|
556
|
+
text: row.bundle.present
|
|
557
|
+
? `${row.bundle.label} · ${row.bundle.exact ? "exact" : "approximate"}`
|
|
558
|
+
: row.bundle.label,
|
|
559
|
+
}),
|
|
560
|
+
dot(row.control.dot, row.control.label),
|
|
561
|
+
]);
|
|
562
|
+
const planError = row.launch.error;
|
|
563
|
+
if (planError !== null) {
|
|
564
|
+
strip.appendChild(el("span", { class: "muted gated-why", text: planError.message }));
|
|
565
|
+
if (planError.action !== null) {
|
|
566
|
+
const btn = el("button", {
|
|
567
|
+
class: "btn",
|
|
568
|
+
type: "button",
|
|
569
|
+
text: planError.action.label,
|
|
570
|
+
title: planError.action.hint,
|
|
571
|
+
});
|
|
572
|
+
if (planError.action.jobKind === null) {
|
|
573
|
+
btn.disabled = true;
|
|
574
|
+
} else {
|
|
575
|
+
btn.addEventListener("click", async () => {
|
|
576
|
+
btn.disabled = true;
|
|
577
|
+
try {
|
|
578
|
+
await api.submitJob(ctx.id, planError.action.jobKind);
|
|
579
|
+
toast(`Queued ${planError.action.jobKind}`, "info");
|
|
580
|
+
ctx.reload();
|
|
581
|
+
} catch (err) {
|
|
582
|
+
btn.disabled = false;
|
|
583
|
+
toast(`Could not queue: ${err instanceof Error ? err.message : String(err)}`);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
strip.appendChild(btn);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return strip;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Capability-badge chips from the detail payload's `badges` booleans. */
|
|
594
|
+
function badgeStrip(badges) {
|
|
595
|
+
if (!badges || typeof badges !== "object") return null;
|
|
596
|
+
const active = Object.keys(badges).filter((k) => badges[k] === true);
|
|
597
|
+
if (active.length === 0) return null;
|
|
598
|
+
return el(
|
|
599
|
+
"div",
|
|
600
|
+
{ class: "safety-strip", "aria-label": "capabilities" },
|
|
601
|
+
active.map((k) => el("span", { class: "chip", text: k })),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function renderNotFound(root, route) {
|
|
606
|
+
clear(root).appendChild(
|
|
607
|
+
el("div", { class: "card" }, [
|
|
608
|
+
el("h2", { text: "Nothing at this address" }),
|
|
609
|
+
el("p", { class: "muted mono", text: String(route.hash ?? "") }),
|
|
610
|
+
el("a", { href: hrefLibrary(), text: "← back to Library" }),
|
|
611
|
+
]),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function renderError(root, err) {
|
|
616
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
617
|
+
clear(root).appendChild(
|
|
618
|
+
el("div", { class: "card error-card" }, [
|
|
619
|
+
el("h2", null, [dot("bad", "request failed")]),
|
|
620
|
+
el("p", { class: "muted", text: message }),
|
|
621
|
+
el("button", {
|
|
622
|
+
class: "btn",
|
|
623
|
+
type: "button",
|
|
624
|
+
text: "Retry",
|
|
625
|
+
onClick: () => dispatch(parseRoute(window.location.hash)),
|
|
626
|
+
}),
|
|
627
|
+
]),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* HM-190 — the app-level keyboard layer.
|
|
633
|
+
*
|
|
634
|
+
* One listener, one pure reducer (`keys.js`). It claims ⌘K, Escape and — on
|
|
635
|
+
* screens with no inbox — `?`, and it claims NOTHING else, so the inbox
|
|
636
|
+
* triage reducer that shipped with M2 keeps receiving j/k/g/d untouched on
|
|
637
|
+
* the two screens that mount it.
|
|
638
|
+
*/
|
|
639
|
+
function installKeyboard() {
|
|
640
|
+
document.addEventListener("keydown", (event) => {
|
|
641
|
+
const { state, effect, claimed } = globalKey(keyState, keyShape(event));
|
|
642
|
+
keyState = state;
|
|
643
|
+
if (claimed) event.preventDefault();
|
|
644
|
+
if (effect === "open-omnibox") openOverlay();
|
|
645
|
+
else if (effect === "close-omnibox") closeOverlay();
|
|
646
|
+
else if (effect === "toggle-sheet") drawSheet();
|
|
647
|
+
else if (effect === "close-sheet") drawSheet();
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function openOverlay() {
|
|
652
|
+
if (omnibox !== null) return;
|
|
653
|
+
keyState = { ...keyState, omniboxOpen: true, sheetOpen: false };
|
|
654
|
+
drawSheet();
|
|
655
|
+
omnibox = openOmnibox(document.body, {
|
|
656
|
+
onClose: () => {
|
|
657
|
+
omnibox = null;
|
|
658
|
+
keyState = { ...keyState, omniboxOpen: false };
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function closeOverlay() {
|
|
664
|
+
if (omnibox === null) return;
|
|
665
|
+
omnibox.close();
|
|
666
|
+
omnibox = null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The `?` bindings overlay. Rendered from `bindingSheet()` so the list and
|
|
670
|
+
* the reducer can never disagree about what a key does. */
|
|
671
|
+
function drawSheet() {
|
|
672
|
+
if (sheetNode !== null) {
|
|
673
|
+
sheetNode.remove();
|
|
674
|
+
sheetNode = null;
|
|
675
|
+
}
|
|
676
|
+
if (!keyState.sheetOpen) return;
|
|
677
|
+
sheetNode = el(
|
|
678
|
+
"div",
|
|
679
|
+
{
|
|
680
|
+
class: "sheet-backdrop",
|
|
681
|
+
role: "dialog",
|
|
682
|
+
"aria-modal": "true",
|
|
683
|
+
"aria-label": "keyboard bindings",
|
|
684
|
+
},
|
|
685
|
+
[
|
|
686
|
+
el("div", { class: "card sheet" }, [
|
|
687
|
+
el("h3", { class: "card-title", text: "Keyboard" }),
|
|
688
|
+
...bindingSheet(keyState.inboxMounted).flatMap((group) => [
|
|
689
|
+
el("h4", { class: "sheet-section", text: group.section }),
|
|
690
|
+
el(
|
|
691
|
+
"ul",
|
|
692
|
+
{ class: "check-list" },
|
|
693
|
+
group.bindings.map((b) =>
|
|
694
|
+
el("li", null, [
|
|
695
|
+
el("kbd", { class: "mono", text: b.keys }),
|
|
696
|
+
el("span", { text: ` — ${b.does}` }),
|
|
697
|
+
]),
|
|
698
|
+
),
|
|
699
|
+
),
|
|
700
|
+
]),
|
|
701
|
+
el("p", { class: "muted small", text: "Esc closes." }),
|
|
702
|
+
]),
|
|
703
|
+
],
|
|
704
|
+
);
|
|
705
|
+
sheetNode.addEventListener("click", (event) => {
|
|
706
|
+
if (event.target === sheetNode) {
|
|
707
|
+
keyState = { ...keyState, sheetOpen: false };
|
|
708
|
+
drawSheet();
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
document.body.appendChild(sheetNode);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function boot() {
|
|
715
|
+
bootstrapToken();
|
|
716
|
+
initTheme();
|
|
717
|
+
const appRoot = document.getElementById("app");
|
|
718
|
+
renderShell(appRoot);
|
|
719
|
+
onUnauthorized(() => {
|
|
720
|
+
renderTokenScreen(viewRoot, () => {
|
|
721
|
+
loadVersion();
|
|
722
|
+
loadNavCounts();
|
|
723
|
+
// The polls that ran while the token was missing answered 401 and
|
|
724
|
+
// returned nothing; take a reading now rather than at the next tick.
|
|
725
|
+
notifications.refresh();
|
|
726
|
+
dispatch(parseRoute(window.location.hash));
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
startRouter((route) => {
|
|
730
|
+
dispatch(route);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (typeof document !== "undefined") boot();
|