conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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:
Massets/style.css | 13+++++++++++--
Msrc/conductor/ui/pages.js | 33+++++++++++++++++++--------------
Mtest/ui.test.js | 27+++++++++++++++++++++++++++
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');