@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,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev & MCP — the two remaining supervised run classes: the `serve --mcp` /
|
|
3
|
+
* `expose:` projection, and the `crewhaus dev` watch-recompile-relaunch
|
|
4
|
+
* loop.
|
|
5
|
+
*
|
|
6
|
+
* Both are PROCESSES, not panels: they go through the manager's own process
|
|
7
|
+
* layer and get a row in the harness's run ledger, so the next manager boot
|
|
8
|
+
* still sees them in history. Their live output is the EXISTING run console —
|
|
9
|
+
* these screens hand off a `runId` to `#/h/<id>/runs/<runId>` rather than
|
|
10
|
+
* opening a second stream.
|
|
11
|
+
*
|
|
12
|
+
* The reason dev mode is driven from here at all: `crewhaus dev` normally
|
|
13
|
+
* compiles into a temp directory and runs with that as the cwd, so sessions,
|
|
14
|
+
* memory and feedback land somewhere that disappears. The manager owns the
|
|
15
|
+
* spawn and sets the cwd to the harness dir, which keeps state in the
|
|
16
|
+
* harness's own `.crewhaus/` across every recompile. The card says which
|
|
17
|
+
* state roots are actually anchored rather than claiming a blanket fix.
|
|
18
|
+
*
|
|
19
|
+
* THREE PROJECTION ANSWERS, NEVER CONFLATED. A `cli` shape projects through a
|
|
20
|
+
* separate `crewhaus serve --mcp` process the manager can start; a
|
|
21
|
+
* channel/managed daemon self-exposes from its own compiled bundle, so the
|
|
22
|
+
* honest control there is the daemon's Start, not a second one; every other
|
|
23
|
+
* shape does not project at all. A Start button on the second or third case
|
|
24
|
+
* would be a dead button, so it is not offered — the reason is.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { api } from "../api.js";
|
|
28
|
+
import { asOf, clear, collapsible, dot, el, emptyState, skeleton, toast } from "../dom.js";
|
|
29
|
+
import { hrefHarness } from "../router.js";
|
|
30
|
+
import { fmtRelativeTime } from "../util.js";
|
|
31
|
+
import { cliTwin, gatedBtn, runAction } from "./control.js";
|
|
32
|
+
|
|
33
|
+
export async function renderRuntime(root, ctx) {
|
|
34
|
+
clear(root).appendChild(skeleton(6));
|
|
35
|
+
const [mcp, dev] = await Promise.all([api.mcpServers({ id: ctx.id }), api.dev({ id: ctx.id })]);
|
|
36
|
+
clear(root);
|
|
37
|
+
const reload = () => renderRuntime(root, ctx);
|
|
38
|
+
root.appendChild(mcpCard(ctx, mcp, reload));
|
|
39
|
+
root.appendChild(devCard(ctx, dev, reload));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function errorCard(title, res) {
|
|
43
|
+
const message =
|
|
44
|
+
typeof res?.body?.error === "string" ? res.body.error : `HTTP ${res ? res.status : "?"}`;
|
|
45
|
+
return el("section", { class: "card error-card" }, [
|
|
46
|
+
el("h3", { class: "card-title" }, [dot("bad", title)]),
|
|
47
|
+
el("p", { class: "muted", text: message }),
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A write whose REFUSAL is a payload: `{ok:false, code, reason}`. Reporting
|
|
52
|
+
* it is the whole point — a refusal is information, not an error toast. */
|
|
53
|
+
function reportWrite(res, label, reload) {
|
|
54
|
+
if (!res) return;
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
toast(`${label} failed: ${res.body?.error ?? `HTTP ${res.status}`}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const body = res.body ?? {};
|
|
60
|
+
if (body.ok === false) {
|
|
61
|
+
toast(String(body.reason ?? `${label} was refused`));
|
|
62
|
+
} else if (body.started === true) {
|
|
63
|
+
toast(`${label}: started — watch it in the run console`, "info");
|
|
64
|
+
} else if (body.stopped === false) {
|
|
65
|
+
// "Nothing was signalled" must never read as "stopped".
|
|
66
|
+
toast(String(body.message ?? `${label} signalled nothing`));
|
|
67
|
+
} else if (body.stopped === true) {
|
|
68
|
+
toast(String(body.message ?? `${label}: stopped`), "info");
|
|
69
|
+
}
|
|
70
|
+
void reload();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The runs table both cards share; each row links into the ONE run feed. */
|
|
74
|
+
function runsTable(ctx, runs) {
|
|
75
|
+
const nowMs = Date.now();
|
|
76
|
+
const tbody = el("tbody");
|
|
77
|
+
for (const run of runs) {
|
|
78
|
+
const runId = typeof run.runId === "string" ? run.runId : null;
|
|
79
|
+
tbody.appendChild(
|
|
80
|
+
el("tr", null, [
|
|
81
|
+
el("td", null, [
|
|
82
|
+
runId === null
|
|
83
|
+
? el("span", { class: "mono muted", text: String(run.jobId ?? "—") })
|
|
84
|
+
: el("a", {
|
|
85
|
+
class: "mono",
|
|
86
|
+
href: hrefHarness(ctx.id, "runs", runId),
|
|
87
|
+
text: runId,
|
|
88
|
+
}),
|
|
89
|
+
]),
|
|
90
|
+
el("td", null, [stateDot(String(run.state ?? "unknown"))]),
|
|
91
|
+
el("td", { class: "mono", text: (run.argv ?? []).join(" ") }),
|
|
92
|
+
el("td", {
|
|
93
|
+
text:
|
|
94
|
+
typeof run.startedAt === "string"
|
|
95
|
+
? fmtRelativeTime(run.startedAt, nowMs)
|
|
96
|
+
: `queued ${fmtRelativeTime(String(run.enqueuedAt ?? ""), nowMs)}`,
|
|
97
|
+
}),
|
|
98
|
+
el("td", {
|
|
99
|
+
class: "num",
|
|
100
|
+
text: typeof run.exitCode === "number" ? String(run.exitCode) : "—",
|
|
101
|
+
}),
|
|
102
|
+
el("td", { class: "muted", text: typeof run.error === "string" ? run.error : "" }),
|
|
103
|
+
]),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return el("div", { class: "table-scroll" }, [
|
|
107
|
+
el("table", { class: "fleet" }, [
|
|
108
|
+
el(
|
|
109
|
+
"thead",
|
|
110
|
+
null,
|
|
111
|
+
el(
|
|
112
|
+
"tr",
|
|
113
|
+
null,
|
|
114
|
+
["Run", "State", "Command", "When", "Exit", "Note"].map((h) => el("th", { text: h })),
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
tbody,
|
|
118
|
+
]),
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Traffic light + text, always paired. */
|
|
123
|
+
function stateDot(state) {
|
|
124
|
+
if (state === "running") return dot("ok", "running");
|
|
125
|
+
if (state === "pending") return dot("warn", "queued");
|
|
126
|
+
if (state === "done") return dot("ok", "done");
|
|
127
|
+
if (state === "failed") return dot("bad", "failed");
|
|
128
|
+
if (state === "interrupted") return dot("warn", "interrupted (manager restarted)");
|
|
129
|
+
if (state === "cancelled") return dot("off", "cancelled");
|
|
130
|
+
return dot("unknown", state);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// The MCP projection
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function mcpCard(ctx, res, reload) {
|
|
138
|
+
if (!res.ok) return errorCard("The MCP projection could not be read", res);
|
|
139
|
+
const view = res.body ?? {};
|
|
140
|
+
const projection = String(view.projection ?? "none");
|
|
141
|
+
const running = view.running === true;
|
|
142
|
+
const health = view.health && typeof view.health === "object" ? view.health : {};
|
|
143
|
+
const supervision =
|
|
144
|
+
view.supervision && typeof view.supervision === "object" ? view.supervision : {};
|
|
145
|
+
|
|
146
|
+
const kv = (k, v) =>
|
|
147
|
+
el("div", { class: "kv" }, [
|
|
148
|
+
el("span", { class: "kv-k", text: k }),
|
|
149
|
+
typeof v === "string"
|
|
150
|
+
? el("span", { class: "kv-v", text: v })
|
|
151
|
+
: el("span", { class: "kv-v" }, [v]),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const facts = el("div", { class: "proc-facts" }, [
|
|
155
|
+
kv("shape", String(view.target ?? "—")),
|
|
156
|
+
kv(
|
|
157
|
+
"projection",
|
|
158
|
+
projection === "serve"
|
|
159
|
+
? dot("ok", "a separate `serve --mcp` process")
|
|
160
|
+
: projection === "self"
|
|
161
|
+
? dot("unknown", "the daemon exposes itself")
|
|
162
|
+
: dot("off", "this shape does not project"),
|
|
163
|
+
),
|
|
164
|
+
kv("state", running ? dot("ok", "running") : dot("off", "not running")),
|
|
165
|
+
kv("transport", String(view.transport ?? "—")),
|
|
166
|
+
kv("port", typeof view.port === "number" ? String(view.port) : "—"),
|
|
167
|
+
kv(
|
|
168
|
+
"listening",
|
|
169
|
+
health.checked === true
|
|
170
|
+
? el("span", null, [
|
|
171
|
+
health.listening === true
|
|
172
|
+
? dot("ok", "something is bound on loopback")
|
|
173
|
+
: dot("bad", "nothing is bound on that port"),
|
|
174
|
+
typeof health.at === "string" ? asOf(health.at) : null,
|
|
175
|
+
])
|
|
176
|
+
: dot("unknown", "not probed"),
|
|
177
|
+
),
|
|
178
|
+
]);
|
|
179
|
+
if (typeof health.note === "string") {
|
|
180
|
+
facts.appendChild(el("p", { class: "muted reason", text: health.note }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const actions = el("div", { class: "proc-actions" });
|
|
184
|
+
const startGate =
|
|
185
|
+
projection === "serve"
|
|
186
|
+
? { enabled: !running, reason: running ? "a projection is already running" : null }
|
|
187
|
+
: { enabled: false, reason: String(view.projectionNote ?? "this shape does not project") };
|
|
188
|
+
const transportPick = el("select", { class: "search", "aria-label": "transport" });
|
|
189
|
+
for (const option of ["stdio", "http"]) {
|
|
190
|
+
const node = el("option", { value: option, text: option === "http" ? "HTTP + SSE" : "stdio" });
|
|
191
|
+
if (String(view.transport ?? "stdio") === option) node.selected = true;
|
|
192
|
+
transportPick.appendChild(node);
|
|
193
|
+
}
|
|
194
|
+
const startBtn = gatedBtn(
|
|
195
|
+
"Start projection",
|
|
196
|
+
startGate,
|
|
197
|
+
() => {
|
|
198
|
+
runAction(
|
|
199
|
+
startBtn,
|
|
200
|
+
"Start projection",
|
|
201
|
+
() => api.mcpServerStart({ id: ctx.id }, { transport: transportPick.value }),
|
|
202
|
+
(r) => reportWrite(r, "Start projection", reload),
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
"btn btn-primary",
|
|
206
|
+
);
|
|
207
|
+
const stopGate = {
|
|
208
|
+
enabled: running || supervision.stoppable === true,
|
|
209
|
+
reason: supervision.reason ?? "nothing to stop",
|
|
210
|
+
};
|
|
211
|
+
const stopBtn = gatedBtn("Stop", stopGate, () => {
|
|
212
|
+
runAction(
|
|
213
|
+
stopBtn,
|
|
214
|
+
"Stop",
|
|
215
|
+
() => api.mcpServerStop({ id: ctx.id }, {}),
|
|
216
|
+
(r) => reportWrite(r, "Stop", reload),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
actions.appendChild(transportPick);
|
|
220
|
+
actions.appendChild(startBtn);
|
|
221
|
+
actions.appendChild(stopBtn);
|
|
222
|
+
|
|
223
|
+
const reasons = el("div", { class: "proc-reasons" });
|
|
224
|
+
if (!startGate.enabled && startGate.reason) {
|
|
225
|
+
reasons.appendChild(
|
|
226
|
+
el("div", { class: "muted gated-why", text: `start: ${startGate.reason}` }),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (supervision.stoppable !== true && typeof supervision.reason === "string") {
|
|
230
|
+
reasons.appendChild(
|
|
231
|
+
el("div", { class: "muted gated-why", text: `stop: ${supervision.reason}` }),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const runs = Array.isArray(view.runs) ? view.runs : [];
|
|
236
|
+
const card = el("section", { class: "card ov-wide" }, [
|
|
237
|
+
el("h3", { class: "card-title" }, [
|
|
238
|
+
el("span", { text: "MCP projection" }),
|
|
239
|
+
running ? dot("ok", "running") : dot("off", "idle"),
|
|
240
|
+
]),
|
|
241
|
+
el("p", { class: "muted", text: String(view.projectionNote ?? "") }),
|
|
242
|
+
facts,
|
|
243
|
+
el("div", { class: "proc-bar" }, [actions, reasons]),
|
|
244
|
+
cliTwin(
|
|
245
|
+
typeof view.cliTwin === "string" ? view.cliTwin : "",
|
|
246
|
+
"no crewhaus.yaml at the harness root — there is no command to run",
|
|
247
|
+
),
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
if (runs.length === 0) {
|
|
251
|
+
card.appendChild(
|
|
252
|
+
emptyState(
|
|
253
|
+
typeof view.note === "string" && view.note !== ""
|
|
254
|
+
? view.note
|
|
255
|
+
: "This harness has never projected an MCP server",
|
|
256
|
+
typeof view.verb === "string" ? view.verb : null,
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
} else {
|
|
260
|
+
card.appendChild(runsTable(ctx, runs));
|
|
261
|
+
}
|
|
262
|
+
const ledger = Array.isArray(view.ledger) ? view.ledger : [];
|
|
263
|
+
if (ledger.length > 0) {
|
|
264
|
+
card.appendChild(
|
|
265
|
+
collapsible(
|
|
266
|
+
[el("span", { class: "muted", text: `run-ledger rows — ${ledger.length}` })],
|
|
267
|
+
[
|
|
268
|
+
el(
|
|
269
|
+
"div",
|
|
270
|
+
{ class: "check-list" },
|
|
271
|
+
ledger.map((row) =>
|
|
272
|
+
el("div", { class: "check" }, [
|
|
273
|
+
el("span", { class: "mono", text: String(row.runId ?? "") }),
|
|
274
|
+
el("span", { class: "muted", text: (row.argv ?? []).join(" ") }),
|
|
275
|
+
]),
|
|
276
|
+
),
|
|
277
|
+
),
|
|
278
|
+
],
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return card;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Dev mode
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function devCard(ctx, res, reload) {
|
|
290
|
+
if (!res.ok) return errorCard("Dev mode could not be read", res);
|
|
291
|
+
const view = res.body ?? {};
|
|
292
|
+
const running = view.running === true;
|
|
293
|
+
const blocked = view.blocked === true;
|
|
294
|
+
const supervision =
|
|
295
|
+
view.supervision && typeof view.supervision === "object" ? view.supervision : {};
|
|
296
|
+
const lastCompile =
|
|
297
|
+
view.lastCompile && typeof view.lastCompile === "object" ? view.lastCompile : null;
|
|
298
|
+
const roots = Array.isArray(view.stateRoots) ? view.stateRoots : [];
|
|
299
|
+
|
|
300
|
+
const kv = (k, v) =>
|
|
301
|
+
el("div", { class: "kv" }, [
|
|
302
|
+
el("span", { class: "kv-k", text: k }),
|
|
303
|
+
typeof v === "string"
|
|
304
|
+
? el("span", { class: "kv-v", text: v })
|
|
305
|
+
: el("span", { class: "kv-v" }, [v]),
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
const facts = el("div", { class: "proc-facts" }, [
|
|
309
|
+
kv(
|
|
310
|
+
"state",
|
|
311
|
+
running ? dot("ok", `running (${String(view.mode ?? "watch")})`) : dot("off", "not running"),
|
|
312
|
+
),
|
|
313
|
+
kv(
|
|
314
|
+
"run",
|
|
315
|
+
typeof view.runId === "string"
|
|
316
|
+
? el("a", {
|
|
317
|
+
class: "mono",
|
|
318
|
+
href: hrefHarness(ctx.id, "runs", view.runId),
|
|
319
|
+
text: view.runId,
|
|
320
|
+
})
|
|
321
|
+
: "—",
|
|
322
|
+
),
|
|
323
|
+
kv("watching", (Array.isArray(view.watching) ? view.watching : []).join(", ") || "—"),
|
|
324
|
+
kv("cwd", String(view.cwd ?? "—")),
|
|
325
|
+
kv(
|
|
326
|
+
"last compile",
|
|
327
|
+
lastCompile === null
|
|
328
|
+
? dot("unknown", "nothing compiled here yet")
|
|
329
|
+
: el("span", null, [
|
|
330
|
+
lastCompile.state === "done"
|
|
331
|
+
? dot("ok", "clean")
|
|
332
|
+
: lastCompile.state === "failed"
|
|
333
|
+
? dot("bad", `failed (exit ${lastCompile.exitCode ?? "?"})`)
|
|
334
|
+
: dot("warn", String(lastCompile.state ?? "unknown")),
|
|
335
|
+
typeof lastCompile.at === "string" ? asOf(lastCompile.at) : null,
|
|
336
|
+
]),
|
|
337
|
+
),
|
|
338
|
+
]);
|
|
339
|
+
facts.appendChild(el("p", { class: "muted reason", text: String(view.cwdNote ?? "") }));
|
|
340
|
+
|
|
341
|
+
// Which parts of the state tree the manager has actually anchored — the
|
|
342
|
+
// honest version of "dev's temp-cwd trap is fixed".
|
|
343
|
+
const rootList = el("div", { class: "check-list" });
|
|
344
|
+
for (const root of roots) {
|
|
345
|
+
rootList.appendChild(
|
|
346
|
+
el("div", { class: "check" }, [
|
|
347
|
+
root.anchored === true
|
|
348
|
+
? dot("ok", `${String(root.name)} → ${String(root.value)}`)
|
|
349
|
+
: dot("unknown", `${String(root.name)} — not pinned (follows the child's cwd)`),
|
|
350
|
+
]),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const checkOnly = el("input", { type: "checkbox", id: "dev-check-only" });
|
|
355
|
+
const startGate = {
|
|
356
|
+
enabled: !running && !blocked && typeof view.verb === "string",
|
|
357
|
+
reason: blocked
|
|
358
|
+
? String(view.blockedReason ?? "a supervised daemon is running")
|
|
359
|
+
: running
|
|
360
|
+
? "a dev loop is already running"
|
|
361
|
+
: typeof view.verb === "string"
|
|
362
|
+
? null
|
|
363
|
+
: String(view.note ?? "there is nothing to watch"),
|
|
364
|
+
};
|
|
365
|
+
const startBtn = gatedBtn(
|
|
366
|
+
"Start dev loop",
|
|
367
|
+
startGate,
|
|
368
|
+
() => {
|
|
369
|
+
runAction(
|
|
370
|
+
startBtn,
|
|
371
|
+
"Start dev loop",
|
|
372
|
+
() => api.devStart({ id: ctx.id }, { checkOnly: checkOnly.checked }),
|
|
373
|
+
(r) => reportWrite(r, "Start dev loop", reload),
|
|
374
|
+
);
|
|
375
|
+
},
|
|
376
|
+
"btn btn-primary",
|
|
377
|
+
);
|
|
378
|
+
const stopGate = {
|
|
379
|
+
enabled: running || supervision.stoppable === true,
|
|
380
|
+
reason: supervision.reason ?? "nothing to stop",
|
|
381
|
+
};
|
|
382
|
+
const stopBtn = gatedBtn("Stop", stopGate, () => {
|
|
383
|
+
runAction(
|
|
384
|
+
stopBtn,
|
|
385
|
+
"Stop",
|
|
386
|
+
() => api.devStop({ id: ctx.id }, {}),
|
|
387
|
+
(r) => reportWrite(r, "Stop", reload),
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const reasons = el("div", { class: "proc-reasons" });
|
|
392
|
+
if (!startGate.enabled && startGate.reason) {
|
|
393
|
+
reasons.appendChild(
|
|
394
|
+
el("div", { class: "muted gated-why", text: `start: ${startGate.reason}` }),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (supervision.stoppable !== true && typeof supervision.reason === "string") {
|
|
398
|
+
reasons.appendChild(
|
|
399
|
+
el("div", { class: "muted gated-why", text: `stop: ${supervision.reason}` }),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const runs = Array.isArray(view.runs) ? view.runs : [];
|
|
404
|
+
const card = el("section", { class: "card ov-wide" }, [
|
|
405
|
+
el("h3", { class: "card-title" }, [
|
|
406
|
+
el("span", { text: "Dev mode" }),
|
|
407
|
+
running ? dot("ok", `watching (${String(view.mode ?? "watch")})`) : dot("off", "idle"),
|
|
408
|
+
]),
|
|
409
|
+
facts,
|
|
410
|
+
collapsible(
|
|
411
|
+
[el("span", { class: "muted", text: "state roots the manager anchored" })],
|
|
412
|
+
[rootList],
|
|
413
|
+
),
|
|
414
|
+
el("div", { class: "proc-bar" }, [
|
|
415
|
+
el("div", { class: "proc-actions" }, [
|
|
416
|
+
el("label", { class: "muted", for: "dev-check-only" }, [
|
|
417
|
+
checkOnly,
|
|
418
|
+
el("span", { text: " validate only (compile --check --watch)" }),
|
|
419
|
+
]),
|
|
420
|
+
startBtn,
|
|
421
|
+
stopBtn,
|
|
422
|
+
]),
|
|
423
|
+
reasons,
|
|
424
|
+
]),
|
|
425
|
+
cliTwin(
|
|
426
|
+
typeof view.cliTwin === "string" ? view.cliTwin : "",
|
|
427
|
+
"no crewhaus.yaml at the harness root — there is no command to run",
|
|
428
|
+
),
|
|
429
|
+
]);
|
|
430
|
+
|
|
431
|
+
if (runs.length === 0) {
|
|
432
|
+
card.appendChild(
|
|
433
|
+
emptyState(
|
|
434
|
+
typeof view.note === "string" && view.note !== ""
|
|
435
|
+
? view.note
|
|
436
|
+
: "No dev loop has run for this harness",
|
|
437
|
+
typeof view.verb === "string" ? view.verb : null,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
} else {
|
|
441
|
+
card.appendChild(runsTable(ctx, runs));
|
|
442
|
+
}
|
|
443
|
+
card.appendChild(
|
|
444
|
+
el("div", { class: "proc-links" }, [
|
|
445
|
+
el("a", { href: hrefHarness(ctx.id, "runs"), text: "run history →" }),
|
|
446
|
+
el("a", { href: hrefHarness(ctx.id, "overview"), text: "process card →" }),
|
|
447
|
+
]),
|
|
448
|
+
);
|
|
449
|
+
return card;
|
|
450
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedulers — the four-lane timeline (heartbeat, schedule, dream, janitor).
|
|
3
|
+
*
|
|
4
|
+
* THE ASYMMETRY IS THE SCREEN. A daemon's schedulers are in-process timers,
|
|
5
|
+
* so the CADENCE is declared in the spec and knowable offline, while the
|
|
6
|
+
* PHASE — last fired, next due — is knowable only inside the process that
|
|
7
|
+
* armed the timer. That is why `crewhaus.control.v1` exists, and why a lane
|
|
8
|
+
* with no reachable control plane says "cadence only" and names the reason
|
|
9
|
+
* instead of leaving a blank that reads as a gap.
|
|
10
|
+
*
|
|
11
|
+
* Two lanes can be poked (control.v1 arms them); dream and janitor are
|
|
12
|
+
* read-only rows that say why. Wake distinguishes `tick_in_flight` (a tick
|
|
13
|
+
* is running — retry) from `draining` (this daemon is going away).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { api } from "../api.js";
|
|
17
|
+
import { clear, collapsible, dot, el, emptyState, jsonPre, skeleton } from "../dom.js";
|
|
18
|
+
import { laneModels } from "../supervision.js";
|
|
19
|
+
import { controlBtn } from "./control.js";
|
|
20
|
+
import { jobButton } from "./jobs.js";
|
|
21
|
+
|
|
22
|
+
export async function renderSchedulers(root, ctx) {
|
|
23
|
+
clear(root).appendChild(skeleton(5));
|
|
24
|
+
const view = await api.schedulers(ctx.id);
|
|
25
|
+
clear(root);
|
|
26
|
+
if (view === null) {
|
|
27
|
+
root.appendChild(emptyState("Nothing scheduled here yet", "crewhaus daemon start"));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const reload = () => renderSchedulers(root, ctx);
|
|
31
|
+
const nowMs = Date.now();
|
|
32
|
+
const lanes = laneModels(view, nowMs);
|
|
33
|
+
const dir = ctx.dir ?? "";
|
|
34
|
+
|
|
35
|
+
root.appendChild(
|
|
36
|
+
el("div", { class: "rollup" }, [
|
|
37
|
+
view.controlReachable === true
|
|
38
|
+
? dot("ok", "control.v1 answered — phase columns are live")
|
|
39
|
+
: dot("off", "no control plane — cadence only"),
|
|
40
|
+
view.controlReachable !== true && typeof view.controlReason === "string"
|
|
41
|
+
? el("span", { class: "muted", text: view.controlReason })
|
|
42
|
+
: null,
|
|
43
|
+
view.draining === true ? el("span", { class: "chip chip-warn", text: "draining" }) : null,
|
|
44
|
+
]),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (lanes.length === 0) {
|
|
48
|
+
root.appendChild(emptyState("This spec declares no scheduler lanes", "crewhaus compile"));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const grid = el("div", { class: "lanes" });
|
|
53
|
+
// The fleet-level reason is already on the line above; repeating it in all
|
|
54
|
+
// four lanes buries the per-lane facts under one sentence said four times.
|
|
55
|
+
const headerReason = typeof view.controlReason === "string" ? view.controlReason : null;
|
|
56
|
+
for (const lane of lanes) grid.appendChild(laneCard(lane, headerReason, ctx, dir, reload));
|
|
57
|
+
root.appendChild(grid);
|
|
58
|
+
|
|
59
|
+
if (view.counters && typeof view.counters === "object") {
|
|
60
|
+
root.appendChild(
|
|
61
|
+
collapsible(
|
|
62
|
+
[el("span", { class: "muted", text: "control.v1 counters" })],
|
|
63
|
+
[jsonPre(view.counters)],
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function laneCard(lane, headerReason, ctx, dir, reload) {
|
|
70
|
+
const phase = el("div", { class: "lane-phase" }, [
|
|
71
|
+
el("div", { class: "kv" }, [
|
|
72
|
+
el("span", { class: "kv-k", text: "cadence" }),
|
|
73
|
+
el("span", { class: "kv-v" }, [
|
|
74
|
+
el("span", { text: lane.cadence ?? "—" }),
|
|
75
|
+
el("span", { class: "chip", text: `from ${lane.cadenceSource}` }),
|
|
76
|
+
]),
|
|
77
|
+
]),
|
|
78
|
+
el("div", { class: "kv" }, [
|
|
79
|
+
el("span", { class: "kv-k", text: "last fired" }),
|
|
80
|
+
el("span", { class: "kv-v" }, [
|
|
81
|
+
el("span", { text: lane.lastFiredLabel }),
|
|
82
|
+
lane.lastOutcome !== null ? el("span", { class: "chip", text: lane.lastOutcome }) : null,
|
|
83
|
+
]),
|
|
84
|
+
]),
|
|
85
|
+
el("div", { class: "kv" }, [
|
|
86
|
+
el("span", { class: "kv-k", text: "next due" }),
|
|
87
|
+
el("span", { class: "kv-v" }, [
|
|
88
|
+
el("span", { text: lane.nextDueLabel }),
|
|
89
|
+
// Honest about WHY the phase is missing — never an implied gap. The
|
|
90
|
+
// fleet-level reason is on the header line, so only a LANE-specific
|
|
91
|
+
// one repeats here.
|
|
92
|
+
lane.phaseNote !== null && lane.phaseNote !== headerReason
|
|
93
|
+
? el("div", { class: "muted reason", text: lane.phaseNote })
|
|
94
|
+
: null,
|
|
95
|
+
]),
|
|
96
|
+
]),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const actions = el("div", { class: "lane-actions" });
|
|
100
|
+
if (lane.lane === "heartbeat" || lane.lane === "schedule") {
|
|
101
|
+
actions.appendChild(
|
|
102
|
+
controlBtn(
|
|
103
|
+
"Wake",
|
|
104
|
+
{ enabled: lane.pokeable, reason: lane.pokeReason },
|
|
105
|
+
() => api.controlWake(ctx.id, lane.lane, "poked from the Hangar console"),
|
|
106
|
+
{ action: "wake", dir, lane: lane.lane },
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
} else if (lane.lane === "dream") {
|
|
110
|
+
// Dream is a read-only timer row in control.v1 — the honest "run now" is
|
|
111
|
+
// the job queue, not a poke.
|
|
112
|
+
actions.appendChild(jobButton("Run now", ctx.id, "dream-run", undefined, reload));
|
|
113
|
+
if (lane.pokeReason !== null) {
|
|
114
|
+
actions.appendChild(el("div", { class: "muted gated-why", text: lane.pokeReason }));
|
|
115
|
+
}
|
|
116
|
+
} else if (lane.pokeReason !== null) {
|
|
117
|
+
actions.appendChild(el("div", { class: "muted gated-why", text: lane.pokeReason }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const detailNode =
|
|
121
|
+
lane.detail !== null && typeof lane.detail === "object"
|
|
122
|
+
? collapsible([el("span", { class: "muted", text: "lane detail" })], [jsonPre(lane.detail)])
|
|
123
|
+
: null;
|
|
124
|
+
|
|
125
|
+
return el("section", { class: `card lane-card${lane.armed ? "" : " lane-off"}` }, [
|
|
126
|
+
el("h3", { class: "card-title" }, [el("span", { text: lane.lane }), dot(lane.dot, lane.label)]),
|
|
127
|
+
lane.armed
|
|
128
|
+
? phase
|
|
129
|
+
: el("p", { class: "muted", text: "the spec declares no such block — absent, not broken" }),
|
|
130
|
+
lane.armed ? actions : null,
|
|
131
|
+
lane.armed ? detailNode : null,
|
|
132
|
+
]);
|
|
133
|
+
}
|