omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/dashboard/app.js
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
|
-
// The dashboard UI (conductor #293): ask for the bearer token once,
|
|
2
|
-
// localStorage, and render
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The dashboard UI (conductor #293 + #294): ask for the bearer token once,
|
|
2
|
+
// keep it in localStorage, and render the fleet monitoring surface — the fleet
|
|
3
|
+
// overview, per-project board/ledger/reports and run detail — from the
|
|
4
|
+
// read-only `/api/*` producers. Every row points at the same producer the CLI
|
|
5
|
+
// renders, and a degraded or unreachable daemon renders DEGRADED rather than a
|
|
6
|
+
// fabricated healthy empty row.
|
|
5
7
|
//
|
|
6
8
|
// Plain browser JS, no runtime deps: the page is served unauthenticated and
|
|
7
|
-
// the token lives only in the browser's localStorage, never in a URL.
|
|
9
|
+
// the token lives only in the browser's localStorage, never in a URL. Data
|
|
10
|
+
// refreshes by polling every 8s — no websockets in this slice.
|
|
8
11
|
|
|
9
12
|
const TOKEN_KEY = "omp-conductor-dashboard-token";
|
|
13
|
+
const POLL_MS = 8000;
|
|
14
|
+
|
|
10
15
|
const tokenPanel = document.getElementById("token-panel");
|
|
11
16
|
const tokenForm = document.getElementById("token-form");
|
|
12
17
|
const tokenInput = document.getElementById("token-input");
|
|
13
18
|
const tokenError = document.getElementById("token-error");
|
|
14
19
|
const fleet = document.getElementById("fleet");
|
|
15
20
|
const projects = document.getElementById("projects");
|
|
21
|
+
const projectView = document.getElementById("project");
|
|
22
|
+
const projectTitle = document.getElementById("project-title");
|
|
23
|
+
const projectDaemon = document.getElementById("project-daemon");
|
|
24
|
+
const projectSummary = document.getElementById("project-summary");
|
|
25
|
+
const tabsNav = document.getElementById("tabs");
|
|
26
|
+
const panelBoard = document.getElementById("panel-board");
|
|
27
|
+
const panelLedger = document.getElementById("panel-ledger");
|
|
28
|
+
const panelReports = document.getElementById("panel-reports");
|
|
29
|
+
const runDetail = document.getElementById("run-detail");
|
|
30
|
+
const runTitle = document.getElementById("run-title");
|
|
31
|
+
const runAttempts = document.getElementById("run-attempts");
|
|
16
32
|
|
|
17
33
|
/** Human verdict per daemon state — "down" spelled out by design. */
|
|
18
34
|
const STATE_TEXT = {
|
|
@@ -22,22 +38,45 @@ const STATE_TEXT = {
|
|
|
22
38
|
"other-project": "daemon down — serves another project",
|
|
23
39
|
};
|
|
24
40
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
/** The board's lane titles, fixed by `COLUMN_DEFS` in board.ts. */
|
|
42
|
+
const LANE_TITLES = {
|
|
43
|
+
queue: "QUEUE",
|
|
44
|
+
claimed: "CLAIMED",
|
|
45
|
+
running: "RUNNING",
|
|
46
|
+
green: "GREEN",
|
|
47
|
+
blocked: "BLOCKED",
|
|
48
|
+
failed: "FAILED",
|
|
49
|
+
orphaned: "ORPHANED",
|
|
50
|
+
merged: "MERGED",
|
|
51
|
+
settled: "SETTLED",
|
|
52
|
+
parked: "PARKED",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let token = localStorage.getItem(TOKEN_KEY);
|
|
56
|
+
let currentProject = null;
|
|
57
|
+
let currentTab = "board";
|
|
58
|
+
let pollTimer = null;
|
|
59
|
+
|
|
60
|
+
function el(tag, className, text) {
|
|
61
|
+
const node = document.createElement(tag);
|
|
62
|
+
if (className !== undefined && className !== null && className !== "") node.className = className;
|
|
63
|
+
if (text !== undefined && text !== null) node.textContent = String(text);
|
|
64
|
+
return node;
|
|
32
65
|
}
|
|
33
66
|
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
67
|
+
function clear(node) {
|
|
68
|
+
node.replaceChildren();
|
|
69
|
+
return node;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function api(path) {
|
|
73
|
+
const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` } });
|
|
74
|
+
if (res.status === 401) {
|
|
75
|
+
showTokenPrompt();
|
|
76
|
+
throw new Error("token refused");
|
|
77
|
+
}
|
|
78
|
+
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
|
|
79
|
+
return res.json();
|
|
41
80
|
}
|
|
42
81
|
|
|
43
82
|
function daemonLine(view) {
|
|
@@ -48,73 +87,373 @@ function daemonLine(view) {
|
|
|
48
87
|
: STATE_TEXT[daemon.state];
|
|
49
88
|
const parts = [text];
|
|
50
89
|
if (daemon.port !== null && daemon.port !== undefined) parts.push(`port ${daemon.port}`);
|
|
51
|
-
if (daemon.state === "ok") parts.push("healthz answering");
|
|
52
90
|
return parts.join(" · ");
|
|
53
91
|
}
|
|
54
92
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
li.className = "project";
|
|
58
|
-
const dot = document.createElement("span");
|
|
59
|
-
dot.className = `dot dot-${view.daemon.state}`;
|
|
60
|
-
dot.setAttribute("aria-hidden", "true");
|
|
61
|
-
const body = document.createElement("div");
|
|
62
|
-
body.className = "body";
|
|
63
|
-
const name = document.createElement("span");
|
|
64
|
-
name.className = "name";
|
|
65
|
-
name.textContent = view.name;
|
|
66
|
-
const repo = document.createElement("span");
|
|
67
|
-
repo.className = "repo";
|
|
68
|
-
repo.textContent = view.repo;
|
|
69
|
-
const state = document.createElement("span");
|
|
70
|
-
state.className = `state state-${view.daemon.state}`;
|
|
71
|
-
state.textContent = daemonLine(view);
|
|
72
|
-
body.append(name, repo, state);
|
|
73
|
-
li.append(dot, body);
|
|
74
|
-
projects.appendChild(li);
|
|
93
|
+
function usd(value) {
|
|
94
|
+
return `$${value.toFixed(2)}`;
|
|
75
95
|
}
|
|
76
96
|
|
|
97
|
+
function baseHealthSummary(baseHealth) {
|
|
98
|
+
if (baseHealth.length === 0) return "base: —";
|
|
99
|
+
return baseHealth
|
|
100
|
+
.map((b) => `${b.repo}@${b.branch ?? "?"}: ${b.verdict}`)
|
|
101
|
+
.join(", ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function showTokenPrompt() {
|
|
105
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
106
|
+
stopPolling();
|
|
107
|
+
token = null;
|
|
108
|
+
tokenInput.value = "";
|
|
109
|
+
tokenError.hidden = true;
|
|
110
|
+
tokenPanel.hidden = false;
|
|
111
|
+
fleet.hidden = true;
|
|
112
|
+
projectView.hidden = true;
|
|
113
|
+
tokenInput.focus();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stopPolling() {
|
|
117
|
+
if (pollTimer !== null) {
|
|
118
|
+
clearInterval(pollTimer);
|
|
119
|
+
pollTimer = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function startPolling() {
|
|
124
|
+
stopPolling();
|
|
125
|
+
pollTimer = setInterval(() => {
|
|
126
|
+
if (token === null) return;
|
|
127
|
+
if (currentProject === null) {
|
|
128
|
+
refreshOverview().catch((err) => renderFleetError(err));
|
|
129
|
+
} else if (!runDetail.hidden) {
|
|
130
|
+
// The run detail is a snapshot; the board behind it keeps polling.
|
|
131
|
+
refreshBoard().catch(() => undefined);
|
|
132
|
+
} else {
|
|
133
|
+
refreshProject(currentTab).catch((err) => renderProjectError(err));
|
|
134
|
+
}
|
|
135
|
+
}, POLL_MS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// fleet overview
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
77
142
|
function showFleet(list) {
|
|
78
|
-
|
|
143
|
+
tokenPanel.hidden = true;
|
|
144
|
+
fleet.hidden = false;
|
|
145
|
+
const out = clear(projects);
|
|
79
146
|
if (list.length === 0) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
li.textContent = "No projects configured.";
|
|
83
|
-
projects.appendChild(li);
|
|
84
|
-
} else {
|
|
85
|
-
for (const view of list) showProject(view);
|
|
147
|
+
out.appendChild(el("li", "project", "No projects configured."));
|
|
148
|
+
return;
|
|
86
149
|
}
|
|
87
|
-
|
|
150
|
+
for (const row of list) {
|
|
151
|
+
const li = el("li", "project is-clickable");
|
|
152
|
+
const dot = el("span", `dot dot-${row.daemon.state}`);
|
|
153
|
+
dot.setAttribute("aria-hidden", "true");
|
|
154
|
+
const body = el("div", "body");
|
|
155
|
+
body.appendChild(el("span", "name", row.name));
|
|
156
|
+
body.appendChild(el("span", "repo", row.repo));
|
|
157
|
+
if (row.degraded) {
|
|
158
|
+
const degraded = el("span", "state state-degraded", "DEGRADED");
|
|
159
|
+
body.appendChild(degraded);
|
|
160
|
+
}
|
|
161
|
+
body.appendChild(el("span", "state state-muted", daemonLine(row)));
|
|
162
|
+
body.appendChild(
|
|
163
|
+
el(
|
|
164
|
+
"span",
|
|
165
|
+
"meta",
|
|
166
|
+
`dispatch ${row.dispatch} · ${row.liveWorkers} worker(s) · ` +
|
|
167
|
+
`${usd(row.spendTodayUsd)} today`,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
const base = el("span", "meta base", baseHealthSummary(row.baseHealth));
|
|
171
|
+
body.appendChild(base);
|
|
172
|
+
li.append(dot, body);
|
|
173
|
+
li.addEventListener("click", () => openProject(row.name));
|
|
174
|
+
out.appendChild(li);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderFleetError(err) {
|
|
179
|
+
const out = clear(projects);
|
|
180
|
+
const li = el("li", "project error", `Could not load the fleet: ${err.message}`);
|
|
181
|
+
out.appendChild(li);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function refreshOverview() {
|
|
185
|
+
const list = await api("/api/overview");
|
|
186
|
+
showFleet(list);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function showFleetView() {
|
|
190
|
+
currentProject = null;
|
|
191
|
+
runDetail.hidden = true;
|
|
192
|
+
projectView.hidden = true;
|
|
88
193
|
fleet.hidden = false;
|
|
194
|
+
tokenPanel.hidden = true;
|
|
195
|
+
refreshOverview().catch(renderFleetError);
|
|
196
|
+
startPolling();
|
|
89
197
|
}
|
|
90
198
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// project view (board / ledger / reports) and run detail
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
function setTab(tab) {
|
|
204
|
+
currentTab = tab;
|
|
205
|
+
for (const button of tabsNav.querySelectorAll("button")) {
|
|
206
|
+
button.classList.toggle("active", button.dataset.tab === tab);
|
|
207
|
+
}
|
|
208
|
+
panelBoard.hidden = tab !== "board";
|
|
209
|
+
panelLedger.hidden = tab !== "ledger";
|
|
210
|
+
panelReports.hidden = tab !== "reports";
|
|
211
|
+
runDetail.hidden = true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function refreshProject(tab) {
|
|
215
|
+
if (tab === "ledger") return refreshLedger();
|
|
216
|
+
if (tab === "reports") return refreshReports();
|
|
217
|
+
return refreshBoard();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderProjectError(err) {
|
|
221
|
+
const target = currentTab === "ledger" ? panelLedger : currentTab === "reports" ? panelReports : panelBoard;
|
|
222
|
+
clear(target).appendChild(el("p", "error", `Could not load: ${err.message}`));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function openProject(name) {
|
|
226
|
+
currentProject = name;
|
|
227
|
+
projectTitle.textContent = name;
|
|
228
|
+
fleet.hidden = true;
|
|
229
|
+
projectView.hidden = false;
|
|
230
|
+
runDetail.hidden = true;
|
|
231
|
+
setTab("board");
|
|
232
|
+
// The shared daemon verdict (`/api/projects`), shown under the project title
|
|
233
|
+
// — DEGRADED when the daemon is unreachable or serves another project.
|
|
234
|
+
const rows = await api("/api/projects");
|
|
235
|
+
const row = rows.find((r) => r.name === name);
|
|
236
|
+
if (row === undefined) {
|
|
237
|
+
projectDaemon.textContent = "";
|
|
238
|
+
} else {
|
|
239
|
+
projectDaemon.textContent = daemonLine(row);
|
|
240
|
+
projectDaemon.classList.toggle(
|
|
241
|
+
"state-degraded",
|
|
242
|
+
row.daemon.state === "unreachable" || row.daemon.state === "other-project",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
await refreshProject("board");
|
|
246
|
+
startPolling();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function refreshBoard() {
|
|
250
|
+
refreshStatusSummary().catch(() => undefined);
|
|
251
|
+
const path = `/api/projects/${encodeURIComponent(currentProject)}/board`;
|
|
252
|
+
const data = await api(path);
|
|
253
|
+
const board = clear(panelBoard);
|
|
254
|
+
const lanes = data.lanes ?? {};
|
|
255
|
+
const row = el("div", "lanes");
|
|
256
|
+
for (const key of Object.keys(LANE_TITLES)) {
|
|
257
|
+
const col = el("div", "lane");
|
|
258
|
+
const header = el("div", "lane-title", `${LANE_TITLES[key]} (${(lanes[key] ?? []).length})`);
|
|
259
|
+
col.appendChild(header);
|
|
260
|
+
const cards = el("div", "cards");
|
|
261
|
+
for (const entry of lanes[key] ?? []) {
|
|
262
|
+
const card = el("button", "card", `#${entry.issue}`);
|
|
263
|
+
card.type = "button";
|
|
264
|
+
const sub = el("span", "card-state", entry.state);
|
|
265
|
+
if (entry.attempt !== undefined) sub.append(` · attempt ${entry.attempt}`);
|
|
266
|
+
card.appendChild(sub);
|
|
267
|
+
card.addEventListener("click", () => showRunDetail(entry.issue));
|
|
268
|
+
cards.appendChild(card);
|
|
269
|
+
}
|
|
270
|
+
if (cards.childNodes.length === 0) cards.appendChild(el("div", "empty", "—"));
|
|
271
|
+
col.appendChild(cards);
|
|
272
|
+
row.appendChild(col);
|
|
273
|
+
}
|
|
274
|
+
board.appendChild(row);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function showRunDetail(issue) {
|
|
278
|
+
runTitle.textContent = `${currentProject} #${issue}`;
|
|
279
|
+
clear(runAttempts);
|
|
280
|
+
runDetail.hidden = false;
|
|
281
|
+
refreshRuns(issue);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function refreshRuns(issue) {
|
|
285
|
+
const { runs } = await api(`/api/projects/${encodeURIComponent(currentProject)}/runs/${issue}`);
|
|
286
|
+
const tbody = clear(runAttempts);
|
|
287
|
+
if (runs.length === 0) {
|
|
288
|
+
tbody.appendChild(el("tr", undefined)).appendChild(el("td", undefined, "no runs recorded"));
|
|
96
289
|
return;
|
|
97
290
|
}
|
|
98
|
-
|
|
99
|
-
|
|
291
|
+
for (const run of runs) {
|
|
292
|
+
const tr = el("tr");
|
|
293
|
+
tr.appendChild(el("td", undefined, `#${run.attempt}`));
|
|
294
|
+
tr.appendChild(el("td", `state-${stateClass(run.state)}`, run.state));
|
|
295
|
+
tr.appendChild(el("td", undefined, usd(run.spendUsd)));
|
|
296
|
+
tr.appendChild(el("td", "mono", run.failureClass ?? "—"));
|
|
297
|
+
tr.appendChild(el("td", "mono", run.prUrl ?? "—"));
|
|
298
|
+
tr.appendChild(el("td", "mono", new Date(run.startedAt).toLocaleString()));
|
|
299
|
+
tbody.appendChild(tr);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function stateClass(state) {
|
|
304
|
+
if (state === "merged" || state === "settled") return "green";
|
|
305
|
+
if (state === "failed" || state === "killed") return "red";
|
|
306
|
+
return "muted";
|
|
100
307
|
}
|
|
101
308
|
|
|
309
|
+
// --- board ---
|
|
310
|
+
|
|
311
|
+
// --- status summary + holds on the project page ---
|
|
312
|
+
|
|
313
|
+
async function refreshStatusSummary() {
|
|
314
|
+
const path = `/api/projects/${encodeURIComponent(currentProject)}/status`;
|
|
315
|
+
const { status, fleetLayers } = await api(path);
|
|
316
|
+
const out = clear(projectSummary);
|
|
317
|
+
const dispatch = status.dispatch;
|
|
318
|
+
const parts = [`dispatch ${fleetLayers.dispatch}`];
|
|
319
|
+
if (status.paused) parts.push("PAUSED");
|
|
320
|
+
if (dispatch !== undefined && dispatch.degraded) parts.push("DEGRADED");
|
|
321
|
+
out.appendChild(el("span", "meta", parts.join(" · ")));
|
|
322
|
+
if (dispatch !== undefined && dispatch.holds.length > 0) {
|
|
323
|
+
const holds = el("ul", "holds");
|
|
324
|
+
for (const hold of dispatch.holds) {
|
|
325
|
+
const sample = hold.issues.length > 0 ? ` (#${hold.issues.join(", #")})` : "";
|
|
326
|
+
holds.appendChild(el("li", undefined, `${hold.reason} ${hold.count}${sample}`));
|
|
327
|
+
}
|
|
328
|
+
out.appendChild(holds);
|
|
329
|
+
}
|
|
330
|
+
const base = el("span", "meta base", baseHealthSummary(status.baseHealth));
|
|
331
|
+
out.appendChild(base);
|
|
332
|
+
out.appendChild(el("span", "meta", `${status.liveWorkers} live worker(s) · ${usd(status.spendTodayUsd)} today`));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- ledger ---
|
|
336
|
+
|
|
337
|
+
async function refreshLedger() {
|
|
338
|
+
const path = `/api/projects/${encodeURIComponent(currentProject)}/ledger`;
|
|
339
|
+
const { verb, turnOverrides } = await api(path);
|
|
340
|
+
const out = clear(panelLedger);
|
|
341
|
+
out.appendChild(el("h3", undefined, `Verb ledger (${verb.length})`));
|
|
342
|
+
const table = el("table", "ledger");
|
|
343
|
+
const thead = el("thead");
|
|
344
|
+
const headRow = el("tr");
|
|
345
|
+
for (const h of ["At", "Verb", "Role", "Decision", "Detail"]) headRow.appendChild(el("th", undefined, h));
|
|
346
|
+
thead.appendChild(headRow);
|
|
347
|
+
const tbody = el("tbody");
|
|
348
|
+
for (const entry of verb) {
|
|
349
|
+
const tr = el("tr");
|
|
350
|
+
tr.appendChild(el("td", "mono", new Date(entry.at).toLocaleString()));
|
|
351
|
+
tr.appendChild(el("td", undefined, entry.verb));
|
|
352
|
+
tr.appendChild(el("td", undefined, entry.role));
|
|
353
|
+
tr.appendChild(el("td", entry.decision === "refused" ? "state-red" : "state-green", entry.decision));
|
|
354
|
+
tr.appendChild(el("td", undefined, entry.detail));
|
|
355
|
+
tbody.appendChild(tr);
|
|
356
|
+
}
|
|
357
|
+
table.append(thead, tbody);
|
|
358
|
+
out.appendChild(table);
|
|
359
|
+
|
|
360
|
+
if (turnOverrides.length > 0) {
|
|
361
|
+
out.appendChild(el("h3", undefined, `Turn overrides (${turnOverrides.length})`));
|
|
362
|
+
const ot = el("table", "ledger");
|
|
363
|
+
const othead = el("thead");
|
|
364
|
+
const ohr = el("tr");
|
|
365
|
+
for (const h of ["Issue", "Max turns", "Set at"]) othead.appendChild(el("th", undefined, h));
|
|
366
|
+
const otbody = el("tbody");
|
|
367
|
+
for (const o of turnOverrides) {
|
|
368
|
+
const tr = el("tr");
|
|
369
|
+
tr.appendChild(el("td", undefined, `#${o.issue}`));
|
|
370
|
+
tr.appendChild(el("td", undefined, String(o.maxTurns)));
|
|
371
|
+
tr.appendChild(el("td", "mono", new Date(o.setAt).toLocaleString()));
|
|
372
|
+
otbody.appendChild(tr);
|
|
373
|
+
}
|
|
374
|
+
ot.append(othead, otbody);
|
|
375
|
+
out.appendChild(ot);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- reports ---
|
|
380
|
+
|
|
381
|
+
async function refreshReports() {
|
|
382
|
+
const path = `/api/projects/${encodeURIComponent(currentProject)}/reports`;
|
|
383
|
+
const { openReports, digestBacklog, heldNotices } = await api(path);
|
|
384
|
+
const out = clear(panelReports);
|
|
385
|
+
out.appendChild(el("h3", undefined, `Open reports (${openReports.length})`));
|
|
386
|
+
const ul = el("ul", "reports");
|
|
387
|
+
if (openReports.length === 0) ul.appendChild(el("li", "empty", "no open reports"));
|
|
388
|
+
for (const report of openReports) {
|
|
389
|
+
const li = el("li");
|
|
390
|
+
li.appendChild(el("span", "state-muted", `${report.kind} · ${new Date(report.createdAt).toLocaleString()}`));
|
|
391
|
+
li.appendChild(el("span", undefined, ` ${report.state}`));
|
|
392
|
+
ul.appendChild(li);
|
|
393
|
+
}
|
|
394
|
+
out.appendChild(ul);
|
|
395
|
+
|
|
396
|
+
out.appendChild(el("h3", undefined, "Digest backlog"));
|
|
397
|
+
const backlog = el("ul", "reports");
|
|
398
|
+
backlog.appendChild(el("li", undefined, `${digestBacklog.materialCount} material event(s) not yet in an accepted digest`));
|
|
399
|
+
if (digestBacklog.heldNoticeCount !== undefined) {
|
|
400
|
+
backlog.appendChild(el("li", undefined, `${digestBacklog.heldNoticeCount} held notice(s) awaiting digest`));
|
|
401
|
+
}
|
|
402
|
+
out.appendChild(backlog);
|
|
403
|
+
|
|
404
|
+
out.appendChild(el("h3", undefined, `Held notices (${heldNotices.length})`));
|
|
405
|
+
const held = el("ul", "reports");
|
|
406
|
+
if (heldNotices.length === 0) held.appendChild(el("li", "empty", "none held"));
|
|
407
|
+
for (const notice of heldNotices) {
|
|
408
|
+
held.appendChild(el("li", undefined, `[${notice.category}] ${notice.summary}`));
|
|
409
|
+
}
|
|
410
|
+
out.appendChild(held);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// wiring
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
document.getElementById("back-to-fleet").addEventListener("click", (event) => {
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
showFleetView();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
document.getElementById("back-to-board").addEventListener("click", (event) => {
|
|
423
|
+
event.preventDefault();
|
|
424
|
+
runDetail.hidden = true;
|
|
425
|
+
refreshBoard().catch((err) => renderProjectError(err));
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
tabsNav.addEventListener("click", (event) => {
|
|
429
|
+
const button = event.target.closest("button");
|
|
430
|
+
if (button === null) return;
|
|
431
|
+
setTab(button.dataset.tab);
|
|
432
|
+
refreshProject(button.dataset.tab).catch((err) => renderProjectError(err));
|
|
433
|
+
});
|
|
434
|
+
|
|
102
435
|
tokenForm.addEventListener("submit", (event) => {
|
|
103
436
|
event.preventDefault();
|
|
104
|
-
|
|
437
|
+
token = tokenInput.value.trim();
|
|
438
|
+
localStorage.setItem(TOKEN_KEY, token);
|
|
439
|
+
tokenPanel.hidden = true;
|
|
440
|
+
fleet.hidden = false;
|
|
441
|
+
refreshOverview().catch((err) => {
|
|
105
442
|
tokenError.textContent = `Could not load the fleet: ${err.message}`;
|
|
106
443
|
tokenError.hidden = false;
|
|
107
444
|
});
|
|
445
|
+
startPolling();
|
|
108
446
|
});
|
|
109
447
|
|
|
110
448
|
// A stored token skips the prompt, but a 401 still clears it and asks again —
|
|
111
449
|
// the server, not the storage, is the source of truth.
|
|
112
|
-
|
|
113
|
-
if (stored === null) {
|
|
450
|
+
if (token === null || token === "") {
|
|
114
451
|
showTokenPrompt();
|
|
115
452
|
} else {
|
|
116
|
-
|
|
453
|
+
tokenPanel.hidden = true;
|
|
454
|
+
refreshOverview().catch((err) => {
|
|
117
455
|
tokenError.textContent = `Could not load the fleet: ${err.message}`;
|
|
118
456
|
showTokenPrompt();
|
|
119
457
|
});
|
|
458
|
+
startPolling();
|
|
120
459
|
}
|
package/src/dashboard/index.html
CHANGED
|
@@ -25,9 +25,36 @@
|
|
|
25
25
|
<p id="token-error" class="error" hidden>That token was refused.</p>
|
|
26
26
|
</form>
|
|
27
27
|
</section>
|
|
28
|
+
|
|
28
29
|
<section id="fleet" hidden>
|
|
29
30
|
<ul id="projects" class="projects"></ul>
|
|
30
31
|
</section>
|
|
32
|
+
|
|
33
|
+
<section id="project" hidden>
|
|
34
|
+
<p class="back"><a href="#" id="back-to-fleet">← fleet</a></p>
|
|
35
|
+
<h2 id="project-title"></h2>
|
|
36
|
+
<p id="project-daemon" class="state"></p>
|
|
37
|
+
<div id="project-summary" class="summary"></div>
|
|
38
|
+
<nav class="tabs" id="tabs">
|
|
39
|
+
<button data-tab="board" type="button">Board</button>
|
|
40
|
+
<button data-tab="ledger" type="button">Ledger</button>
|
|
41
|
+
<button data-tab="reports" type="button">Reports</button>
|
|
42
|
+
</nav>
|
|
43
|
+
<section id="panel-board"></section>
|
|
44
|
+
<section id="panel-ledger" hidden></section>
|
|
45
|
+
<section id="panel-reports" hidden></section>
|
|
46
|
+
|
|
47
|
+
<div id="run-detail" hidden>
|
|
48
|
+
<p class="back"><a href="#" id="back-to-board">← board</a></p>
|
|
49
|
+
<h3 id="run-title"></h3>
|
|
50
|
+
<table class="runs">
|
|
51
|
+
<thead>
|
|
52
|
+
<tr><th>Attempt</th><th>State</th><th>Spend</th><th>Failure class</th><th>PR</th><th>Started</th></tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody id="run-attempts"></tbody>
|
|
55
|
+
</table>
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
31
58
|
</main>
|
|
32
59
|
<script src="/app.js"></script>
|
|
33
60
|
</body>
|