From 42a25038349b8eba6d82c99ce7531cecac5a67b0 Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 28 May 2026 14:22:39 +0200 Subject: [PATCH] tabs: render schedules as a single table with tilted agent columns (closes #535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-schedule card layout with one table: | # | src | next | every | owner | body | …agents… | actions | |---|-----|------|-------|-------|------|----------|---------| | 5 | op | 5m | 10m | mara | "…" | ✓ ✓ . ✓ | ↯ ✎ ✕ | Each schedule is one in the tbody. Agent columns are dynamic — operator + manager + live containers + any "extra" name that appears as a target on some schedule but isn't a current container (same membership rule buildTargetChips uses, so the table + new/edit forms agree on what's addressable). Per-agent cells: - active target → that cancels just that target on click (replaces the per-row ✕ from the old targets sub-table) - cancelled target → muted ✕ glyph (no button; re-add flows through the edit form's targets multi-select) - not a target → empty cell Agent column headers tilt -45° via CSS so each column reads as 28px of horizontal real estate instead of the full word width. Standard rotated-header pattern: 95px-tall with position:relative, inner
positioned absolute at bottom-left, transform rotates about left-bottom. Body cell truncates with ellipsis + full-text title. Description used to be a separate visible block on the card layout; the table folds it into the body cell's title to keep row height tight. If mara wants description visible in-table it's a small follow-up — easier to iterate on a rejection. Edit form expands inline into a colspan'd row underneath the schedule row it edits (instead of inside the card). Wrapper drops the form's background so it reads as a row extension. No backend changes; everything renders from existing schedulesState + containersState. --- frontend/packages/dashboard/src/dashboard.css | 138 ++++++--- frontend/packages/dashboard/src/tabs.js | 288 +++++++++++------- 2 files changed, 281 insertions(+), 145 deletions(-) diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index d8339480..deaa7e71 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -1759,60 +1759,112 @@ body.flow-shell .tabbar .tab.active.tab-link { } .schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; } -.schedules { - display: flex; - flex-direction: column; - gap: 0.5em; -} -.schedule-row { - padding: 0.6em 0.9em; - background: rgba(24, 24, 37, 0.55); - border: 1px solid var(--border); - border-radius: 4px; - display: flex; - flex-direction: column; - gap: 0.4em; -} -.schedule-row.schedule-cancelled { opacity: 0.55; } -.schedule-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.4em; -} -.schedule-description { - color: var(--muted); - font-style: italic; - font-size: 0.95em; -} -.schedule-body { - white-space: pre-wrap; - word-break: break-word; - background: var(--bg); - border: 1px solid var(--border); - border-radius: 3px; - padding: 0.5em 0.7em; -} -.schedule-targets-table { +/* #535 — schedules-as-table. One row per schedule, attribute columns + on the left, one ✓/✕ column per agent in the middle, actions + column on the right. Agent column headers tilt -45° so a stack of + short agent names fits in ~28px each. */ +.schedules-table { width: 100%; border-collapse: collapse; font-size: 0.9em; } -.schedule-targets-table th, -.schedule-targets-table td { - padding: 0.25em 0.6em; +.schedules-table thead th { + vertical-align: bottom; + padding: 0.3em 0.5em; text-align: left; - border-bottom: 1px solid var(--border); -} -.schedule-targets-table th { color: var(--muted); font-weight: normal; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8em; + border-bottom: 1px solid var(--border); +} +.schedules-table-id { width: 3em; } +.schedules-table-body-th { min-width: 12em; } +.schedules-table-actions-th { width: 7em; } +/* Tilted agent column headers (#535). Header cell is narrow (~28px) + and tall (~90px); the inner
rotates -45° about its bottom-left + corner, with a translate to slide the text up alongside the cell + border. The inner carries the actual baseline so the + underline (border-bottom on the cell) aligns with the rotated text. */ +.schedules-table-agent-th { + width: 28px; + min-width: 28px; + height: 95px; + padding: 0 !important; + vertical-align: bottom; + white-space: nowrap; + text-transform: none !important; + letter-spacing: 0 !important; + position: relative; +} +.schedules-table-agent-th > div { + position: absolute; + bottom: 4px; + left: 14px; + transform-origin: left bottom; + transform: rotate(-45deg); + font-family: ui-monospace, 'JetBrains Mono', monospace; + font-size: 0.9em; + color: var(--fg); +} +.schedules-table-agent-th > div > span { + display: inline-block; + padding: 0 4px 1px; +} +.schedules-table tbody td { + padding: 0.3em 0.5em; + border-top: 1px solid var(--border); + vertical-align: middle; +} +.schedules-table-row-cancelled td { opacity: 0.55; } +.schedules-table-body-cell { + max-width: 30em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, 'JetBrains Mono', monospace; + font-size: 0.9em; +} +.schedules-table-check { + width: 28px; + text-align: center; + padding: 0.3em 0 !important; +} +.schedules-table-check-btn { + background: transparent; + border: 0; + color: var(--green); + cursor: pointer; + padding: 0.1em 0.3em; + font-family: inherit; + font-size: 1em; +} +.schedules-table-check-btn:hover:not(:disabled) { + color: var(--red); +} +.schedules-table-check-btn:disabled { + cursor: default; +} +.schedules-table-check-cancelled { + color: var(--muted); + font-size: 0.9em; +} +.schedules-table-actions { + text-align: right; + white-space: nowrap; +} +.schedules-table-actions .btn-inline-small { + margin-left: 0.3em; +} +/* Edit form expands inline into a colspan'd row underneath the + schedule it edits. Wrapper drops the form's normal background so it + reads as "an extension of the row above" instead of a free-floating + card. */ +.schedules-table-edit-row > td { + padding: 0.5em 0.8em 0.8em !important; + background: rgba(24, 24, 37, 0.4); } -.schedule-targets-table tr:last-child td { border-bottom: 0; } -.schedule-target-cancelled td { opacity: 0.5; } .btn-inline-small { font-size: 0.7em; padding: 0.1em 0.45em; diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index d4729cee..e220b423 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -2313,6 +2313,11 @@ window.marked = marked; if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = originalLabel; } } } + // #535: schedules render as a single table — one row per schedule, + // attribute columns + one ✓/✕ column per agent (tilted 45° header + // so a row of agents takes ~28px each instead of full word width), + // actions column on the right. Edit form expands into a colspan'd + // row underneath when its row's `✎` is toggled on. function renderSchedulesList() { const liveRoot = $('schedules-section'); if (!liveRoot) return; @@ -2321,118 +2326,186 @@ window.marked = marked; root.append(el('p', { class: 'empty' }, 'no schedules queued')); return; } - const ul = el('ul', { class: 'schedules' }); - // Active schedules first (still firing), then cancelled tail. + const agents = schedulesTableAgentSet(); + const table = el('table', { class: 'schedules-table' }); + table.append(renderSchedulesTableHead(agents)); + const tbody = el('tbody', {}); const sorted = schedulesState.slice().sort((a, b) => { const aDone = a.cancelled_at_unix ? 1 : 0; const bDone = b.cancelled_at_unix ? 1 : 0; if (aDone !== bDone) return aDone - bDone; return a.next_fire_at_unix - b.next_fire_at_unix; }); - for (const s of sorted) ul.append(renderScheduleCard(s)); - root.append(ul); - }); - } - function renderScheduleCard(s) { - const cancelled = !!s.cancelled_at_unix; - const li = el('li', { class: 'schedule-row' + (cancelled ? ' schedule-cancelled' : '') }); - const head = el('div', { class: 'schedule-head' }); - head.append( - el('span', { class: 'meta' }, '#' + s.id), ' ', - el('span', { class: 'rqe-source rqe-source-' + (s.source && s.source.kind === 'approval' ? 'approval' : 'manual') }, - s.source && s.source.kind === 'approval' ? 'approval' : 'operator'), - ); - if (cancelled) { - head.append(' ', el('span', { class: 'badge badge-muted', title: 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString() }, 'cancelled')); - } else { - const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000); - const dueLabel = dueIn <= 0 - ? `overdue ${fmtAgo(s.next_fire_at_unix)}` - : `next fire in ${fmtDuration(dueIn)}`; - head.append(' ', el('span', { - class: 'meta', - title: new Date(s.next_fire_at_unix * 1000).toISOString(), - }, dueLabel)); - } - if (s.interval_seconds) { - head.append(' ', el('span', - { class: 'badge badge-muted', title: 'recurring' }, - `↻ every ${fmtDuration(s.interval_seconds)}`)); - } else { - head.append(' ', el('span', { class: 'badge badge-muted', title: 'one-shot' }, 'one-shot')); - } - head.append(' ', el('span', { class: 'meta' }, '· owner ' + s.owner)); - li.append(head); - - if (s.description) { - li.append(el('div', { class: 'schedule-description' }, s.description)); - } - const body = el('div', { class: 'schedule-body' }); - appendLinkified(body, s.body); - li.append(body); - - // Targets table — one row per recipient with cancel button + - // last-fire metadata. - const targets = s.targets || []; - if (targets.length) { - const table = el('table', { class: 'schedule-targets-table' }); - const thead = el('thead', {}, el('tr', {}, - el('th', {}, 'target'), - el('th', {}, 'last fire'), - el('th', {}, 'last result'), - el('th', {}, ''), - )); - table.append(thead); - const tbody = el('tbody', {}); - for (const t of targets) { - const tCancelled = !!t.cancelled_at_unix; - const tr = el('tr', { class: tCancelled ? 'schedule-target-cancelled' : '' }); - tr.append(el('td', {}, t.target)); - tr.append(el('td', { class: 'meta' }, - t.last_fired_at_unix - ? fmtAgo(t.last_fired_at_unix) + ' ago' - : '—')); - tr.append(el('td', { class: 'meta' }, t.last_result || '—')); - const actionTd = el('td', {}); - if (!cancelled && !tCancelled) { - const btn = el('button', { type: 'button', class: 'btn btn-deny btn-inline-small' }, '✕'); - btn.title = 'cancel just this target'; - btn.addEventListener('click', () => cancelScheduleTargets(s.id, [t.target])); - actionTd.append(btn); - } else if (tCancelled) { - actionTd.append(el('span', { class: 'meta' }, '✕ cancelled')); + for (const s of sorted) { + tbody.append(renderScheduleRow(s, agents)); + if (editingSchedules.has(s.id) && !s.cancelled_at_unix) { + tbody.append(renderScheduleEditRow(s, agents)); } - tr.append(actionTd); - tbody.append(tr); } table.append(tbody); - li.append(table); + root.append(table); + }); + } + // The set of agent columns in the schedules table: operator + manager + // first, then live containers (sorted), then any extra names that + // appear as a schedule target but aren't in the live container list + // (operator typo, container destroyed mid-schedule, etc.) — same + // membership rule as `buildTargetChips` so the table and the new-/ + // edit-form chip boxes agree on what's addressable. + function schedulesTableAgentSet() { + const seen = new Set(); + const out = []; + const push = (n) => { if (!seen.has(n)) { seen.add(n); out.push(n); } }; + push('operator'); + push('manager'); + const containerNames = Array.from(containersState.values()) + .map((c) => c.name) + .filter((n) => n !== 'manager' && n !== 'operator') + .sort(); + for (const n of containerNames) push(n); + for (const s of schedulesState) { + for (const t of s.targets || []) push(t.target); + } + return out; + } + function renderSchedulesTableHead(agents) { + const thead = el('thead', {}); + const headerRow = el('tr', {}); + headerRow.append( + el('th', { class: 'schedules-table-id' }, '#'), + el('th', {}, 'src'), + el('th', {}, 'next'), + el('th', {}, 'every'), + el('th', {}, 'owner'), + el('th', { class: 'schedules-table-body-th' }, 'body'), + ); + for (const a of agents) { + headerRow.append(el('th', { class: 'schedules-table-agent-th', title: a }, + el('div', {}, el('span', {}, a)))); + } + headerRow.append(el('th', { class: 'schedules-table-actions-th' }, '')); + thead.append(headerRow); + return thead; + } + function renderScheduleRow(s, agents) { + const cancelled = !!s.cancelled_at_unix; + const tr = el('tr', { + class: 'schedules-table-row' + (cancelled ? ' schedules-table-row-cancelled' : ''), + }); + + tr.append(el('td', { class: 'meta schedules-table-id' }, '#' + s.id)); + + const srcKind = s.source && s.source.kind === 'approval' ? 'approval' : 'manual'; + tr.append(el('td', {}, + el('span', { class: 'rqe-source rqe-source-' + srcKind }, + srcKind === 'approval' ? 'approval' : 'operator'))); + + // "next" cell — relative due-in for active schedules, "cancelled" + // for cancelled ones. Both carry the absolute ISO in the title. + const nextCell = el('td', { class: 'meta' }); + if (cancelled) { + nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString(); + nextCell.textContent = 'cancelled'; + } else { + const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000); + nextCell.title = new Date(s.next_fire_at_unix * 1000).toISOString(); + nextCell.textContent = dueIn <= 0 + ? 'overdue ' + fmtAgo(s.next_fire_at_unix) + : fmtDuration(dueIn); + } + tr.append(nextCell); + + tr.append(el('td', { class: 'meta' }, + s.interval_seconds ? '↻ ' + fmtDuration(s.interval_seconds) : 'one-shot')); + + tr.append(el('td', { class: 'meta' }, s.owner)); + + // Body cell — truncates with ellipsis; full body + description (if + // any) on hover. Description used to be a separate visible block on + // the card layout; the table compresses it into the title to keep + // row height tight. Mara nit-flag candidate if she actually wants + // it visible in-table. + const bodyCell = el('td', { class: 'schedules-table-body-cell' }); + const bodyText = s.body || ''; + bodyCell.title = (s.description ? s.description + '\n\n' : '') + bodyText; + bodyCell.textContent = bodyText; + tr.append(bodyCell); + + // Per-agent target cells. Three states: + // - active target → ✓ button that cancels just that target on + // click (same affordance as the per-row ✕ on the old layout's + // targets table) + // - cancelled target → muted ✕ glyph (no button — backend + // re-add flows through the edit form's targets multi-select) + // - not a target → empty cell + const targetByName = new Map(); + for (const t of s.targets || []) targetByName.set(t.target, t); + for (const a of agents) { + const t = targetByName.get(a); + const td = el('td', { class: 'schedules-table-check' }); + if (!t) { + // empty — no button, no glyph + } else if (t.cancelled_at_unix) { + td.title = 'cancelled — ' + + (t.last_fired_at_unix + ? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago' + : 'never fired') + + (t.last_result ? ' · ' + t.last_result : ''); + td.append(el('span', { class: 'schedules-table-check-cancelled' }, '✕')); + } else { + const lastFireDesc = t.last_fired_at_unix + ? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago' + : 'never fired'; + const lastResultDesc = t.last_result ? ' · ' + t.last_result : ''; + const checkBtn = el('button', { + type: 'button', + class: 'schedules-table-check-btn', + }, '✓'); + checkBtn.title = lastFireDesc + lastResultDesc + + (cancelled ? '' : '\nclick to cancel this target'); + if (cancelled) { + checkBtn.disabled = true; + } else { + checkBtn.addEventListener('click', () => cancelScheduleTargets(s.id, [a])); + } + td.append(checkBtn); + } + tr.append(td); } + // Actions cell — fire / edit / cancel-all. Glyph-only to fit a + // compact column; the buttons keep their existing colour classes + // so the visual cue (mauve = fire, yellow = edit, red = cancel) + // carries over from the card layout. + const actionsCell = el('td', { class: 'schedules-table-actions' }); if (!cancelled) { - const actions = el('div', { class: 'schedule-actions' }); - // Fire-now (#467): out-of-band manual pulse. Recurring schedules - // get an extra fan-out without disturbing `next_fire_at`; one-shots - // are consumed (cancelled after fan-out), per damocles's design. - // Disabled when every target is cancelled — there's nothing to - // fire to. Backend: POST /api/schedules/{id}/fire-now. const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix); - const fireBtn = el('button', { type: 'button', class: 'btn btn-fire-now' }, '↯ fire now'); const isOneShot = !s.interval_seconds; + const fireBtn = el('button', { + type: 'button', + class: 'btn btn-fire-now btn-inline-small', + }, '↯'); fireBtn.title = isOneShot - ? 'fire this schedule once, immediately (one-shot — schedule is consumed after the manual fire)' - : 'fire this schedule once, immediately (recurring — next regularly-scheduled fire is unaffected)'; + ? 'fire once — one-shot, consumed after the manual fire' + : 'fire once now — recurring, next regular fire unaffected'; if (!activeTargets.length) { fireBtn.disabled = true; fireBtn.title = 'every target is cancelled — nothing to fire'; } - fireBtn.addEventListener('click', () => fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target), fireBtn)); - actions.append(fireBtn); - const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule' }, - editingSchedules.has(s.id) ? '✎ close edit' : '✎ edit'); - editBtn.title = 'edit body / description / interval / next-fire (targets stay immutable)'; + fireBtn.addEventListener('click', () => + fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target), fireBtn)); + actionsCell.append(fireBtn); + + const editingThis = editingSchedules.has(s.id); + const editBtn = el('button', { + type: 'button', + class: 'btn btn-edit-schedule btn-inline-small', + }, editingThis ? '✎×' : '✎'); + editBtn.title = editingThis + ? 'close edit' + : 'edit body / description / interval / next-fire / targets'; editBtn.addEventListener('click', () => { - if (editingSchedules.has(s.id)) { + if (editingThis) { editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); } else { @@ -2440,17 +2513,28 @@ window.marked = marked; } renderSchedulesList(); }); - actions.append(editBtn); - const cancelAll = el('button', { type: 'button', class: 'btn btn-deny' }, '✕ cancel all'); - cancelAll.title = 'cancel the whole schedule'; - cancelAll.addEventListener('click', () => cancelScheduleAll(s.id)); - actions.append(cancelAll); - li.append(actions); - if (editingSchedules.has(s.id)) { - li.append(renderScheduleEditForm(s)); - } + actionsCell.append(editBtn); + + const cancelBtn = el('button', { + type: 'button', + class: 'btn btn-deny btn-inline-small', + }, '✕'); + cancelBtn.title = 'cancel the whole schedule'; + cancelBtn.addEventListener('click', () => cancelScheduleAll(s.id)); + actionsCell.append(cancelBtn); } - return li; + tr.append(actionsCell); + + return tr; + } + function renderScheduleEditRow(s, agents) { + // colspan = 6 attribute cols + N agent cols + 1 actions col + const colCount = 7 + agents.length; + const tr = el('tr', { class: 'schedules-table-edit-row' }); + const td = el('td', { colspan: String(colCount) }); + td.append(renderScheduleEditForm(s)); + tr.append(td); + return tr; } // #474 — inline edit form. Renders inside the schedule row when the