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