commit 49200bfa627cba63b79b35ebd205221095e28aa1
parent 37e0eda27aeef02be479455ad7cf22502775edfb
Author: finwo <finwo@pm.me>
Date: Mon, 21 Sep 2026 17:38:18 +0200
Render the task graph as one table so its columns line up
Diffstat:
3 files changed, 57 insertions(+), 16 deletions(-)
diff --git a/assets/style.css b/assets/style.css
@@ -104,8 +104,17 @@ tbody tr:hover { background: #1b1f23; }
.row > div { min-width: 7rem; }
.label { color: var(--muted); font-size: .72rem; display: block; text-transform: uppercase; letter-spacing: .04em; }
-.stage { margin: 1rem 0; }
-.stage h3 { color: var(--muted); font-weight: 500; font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; }
+/* Stages are row groups inside the one task table rather than tables of
+ their own, so every stage shares a single set of column widths. The
+ label is a header row, so it already reads as one. */
+.stage-label th { padding-top: 1.2rem; border-bottom-color: transparent; }
+tbody.stage:first-of-type .stage-label th { padding-top: .45rem; }
+tbody.stage tr.stage-label:hover { background: none; }
+
+/* Durations and exit codes are read down the column, not across the row,
+ so they are right aligned and set in figures of one width. */
+td.num { text-align: right; font-variant-numeric: tabular-nums; }
+table.tasks thead th:nth-last-child(-n+2) { text-align: right; }
.logpane { max-height: 32rem; overflow: auto; border: 1px solid var(--border); border-radius: 6px; background: #0b0d0f; }
pre.log {
diff --git a/src/conductor/ui/pages.js b/src/conductor/ui/pages.js
@@ -92,21 +92,26 @@ export function tasksTable(job, tasks) {
stages.get(depth).push(task);
}
+ // One table for the whole graph. A table per stage sizes its own
+ // columns, so the same field landed in a different place in every
+ // stage and the eye had nothing to follow down the page.
return html`<div id="tasks" ${live ? poll(`/partials/jobs/${job.id}/tasks`, 3) : ''}>
- ${[...stages.keys()].sort((a, b) => a - b).map((depth) => html`
- <div class="stage">
- <h3>stage ${depth + 1}</h3>
- <table><tbody>
- ${stages.get(depth).sort((a, b) => a.name.localeCompare(b.name)).map((task) => html`<tr>
- <td><a href="/tasks/${task.id}">${task.name}</a></td>
- <td>${badge(task.state)}</td>
- <td class="muted">${task.arch ?? ''}</td>
- <td class="muted">${task.worker_name ?? ''}</td>
- <td class="muted">${duration(task.started_at, task.finished_at)}</td>
- <td class="muted">${task.exit_code === null ? '' : `exit ${task.exit_code}`}</td>
- </tr>`)}
- </tbody></table>
- </div>`)}
+ <table class="tasks">
+ <thead><tr>
+ <th>task</th><th>state</th><th>arch</th><th>worker</th><th>duration</th><th>exit</th>
+ </tr></thead>
+ ${[...stages.keys()].sort((a, b) => a - b).map((depth) => html`<tbody class="stage">
+ <tr class="stage-label"><th colspan="6">stage ${depth + 1}</th></tr>
+ ${stages.get(depth).sort((a, b) => a.name.localeCompare(b.name)).map((task) => html`<tr>
+ <td><a href="/tasks/${task.id}">${task.name}</a></td>
+ <td>${badge(task.state)}</td>
+ <td class="muted">${task.arch ?? ''}</td>
+ <td class="muted">${task.worker_name ?? ''}</td>
+ <td class="muted num">${duration(task.started_at, task.finished_at)}</td>
+ <td class="muted num">${task.exit_code === null ? '' : `exit ${task.exit_code}`}</td>
+ </tr>`)}
+ </tbody>`)}
+ </table>
</div>`;
}
diff --git a/test/ui.test.js b/test/ui.test.js
@@ -8,6 +8,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { startHarness } from './helpers/harness.js';
import { html, esc, raw, toHtml, attrs } from '../src/conductor/ui/html.js';
+import { tasksTable } from '../src/conductor/ui/pages.js';
async function withUi(options, fn) {
const h = await startHarness({ bootstrap: true, ...options });
@@ -359,6 +360,32 @@ test('a mutation without the htmx header is refused', async () => {
// --- layout details ---
+test('the task graph is one table, so columns line up across stages', () => {
+ const job = { id: 'j1', state: 'running' };
+ const tasks = [
+ { id: 't1', name: 'style', state: 'success', arch: 'x86-64', worker_name: 'w1', started_at: 1, finished_at: 2, exit_code: 0, needs: [] },
+ { id: 't2', name: 'test', state: 'success', arch: null, worker_name: 'w2', started_at: 1, finished_at: 2, exit_code: 0, needs: [] },
+ { id: 't3', name: 'images', state: 'queued', arch: null, worker_name: null, started_at: null, finished_at: null, exit_code: null, needs: ['t1', 't2'] },
+ { id: 't4', name: 'publish', state: 'queued', arch: null, worker_name: null, started_at: null, finished_at: null, exit_code: null, needs: ['t3'] },
+ ];
+
+ const out = toHtml(tasksTable(job, tasks));
+
+ // Three stages, and one table holding all of them. A table per stage
+ // sizes its own columns, which is what put the same field in a
+ // different place in every stage.
+ assert.equal((out.match(/<tbody/g) ?? []).length, 3, 'expected one row group per stage');
+ assert.equal((out.match(/<table/g) ?? []).length, 1, 'stages must share a single table');
+ assert.match(out, /stage 1/);
+ assert.match(out, /stage 3/);
+
+ // Every task row carries the same six cells, so a column means the same
+ // thing all the way down.
+ for (const row of out.match(/<tr>\s*<td>.*?<\/tr>/gs) ?? []) {
+ assert.equal((row.match(/<td/g) ?? []).length, 6, `row has the wrong cell count: ${row}`);
+ }
+});
+
test('the sign in form is centred rather than stranded at the left edge', async () => {
await withUi({}, async (h) => {
const res = await get(h, '/login');