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.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
@@ -1,18 +1,34 @@
1
- // The dashboard UI (conductor #293): ask for the bearer token once, keep it in
2
- // localStorage, and render `/api/projects` with every daemon state explicit
3
- // a stopped, unreachable or foreign daemon renders as down, never as an empty
4
- // healthy fleet.
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
- async function apiProjects(token) {
26
- const res = await fetch("/api/projects", {
27
- headers: { Authorization: `Bearer ${token}` },
28
- });
29
- if (res.status === 401) return null;
30
- if (!res.ok) throw new Error(`/api/projects answered ${res.status}`);
31
- return res.json();
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 showTokenPrompt() {
35
- localStorage.removeItem(TOKEN_KEY);
36
- tokenInput.value = "";
37
- tokenError.hidden = true;
38
- tokenPanel.hidden = false;
39
- fleet.hidden = true;
40
- tokenInput.focus();
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 showProject(view) {
56
- const li = document.createElement("li");
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
- projects.replaceChildren();
143
+ tokenPanel.hidden = true;
144
+ fleet.hidden = false;
145
+ const out = clear(projects);
79
146
  if (list.length === 0) {
80
- const li = document.createElement("li");
81
- li.className = "project";
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
- tokenPanel.hidden = true;
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
- async function start(token) {
92
- tokenError.hidden = true;
93
- const list = await apiProjects(token);
94
- if (list === null) {
95
- showTokenPrompt();
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
- localStorage.setItem(TOKEN_KEY, token);
99
- showFleet(list);
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
- start(tokenInput.value.trim()).catch((err) => {
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
- const stored = localStorage.getItem(TOKEN_KEY);
113
- if (stored === null) {
450
+ if (token === null || token === "") {
114
451
  showTokenPrompt();
115
452
  } else {
116
- start(stored).catch((err) => {
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
  }
@@ -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>