Spaces:
Running
Running
Agent Manager commited on
Commit ·
a96dad4
1
Parent(s): 9ef3ac2
Make cron table columns content-sized
Browse files- web/src/components/CronSettings.tsx +7 -8
- web/src/styles.css +11 -11
- web/test/cronSettings.test.mjs +33 -5
web/src/components/CronSettings.tsx
CHANGED
|
@@ -73,11 +73,7 @@ const when = (iso: string | null, zone: string, now: number) => {
|
|
| 73 |
const delta = value - now;
|
| 74 |
if (delta > 0 && delta < 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 60_000))}m`;
|
| 75 |
if (delta > 0 && delta < 24 * 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 3_600_000))}h`;
|
| 76 |
-
|
| 77 |
-
return new Intl.DateTimeFormat(undefined, {
|
| 78 |
-
timeZone: zone, weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
| 79 |
-
}).format(new Date(value));
|
| 80 |
-
} catch { return new Date(value).toLocaleString(); }
|
| 81 |
};
|
| 82 |
const compactWhen = (iso: string, zone: string) => {
|
| 83 |
const value = Date.parse(iso);
|
|
@@ -290,19 +286,22 @@ export default function CronSettings({ clis }: { clis: Cli[] }) {
|
|
| 290 |
<thead><tr><th>Job</th><th>Agent</th><th>Type</th><th>Interval</th><th>State</th><th>Next</th><th>Last run</th><th>Actions</th></tr></thead>
|
| 291 |
<tbody>{jobs.map((job) => {
|
| 292 |
const type = clis.find((candidate) => candidate.id === job.agent.cli)?.label || job.agent.cli;
|
|
|
|
|
|
|
|
|
|
| 293 |
return (
|
| 294 |
<tr
|
| 295 |
key={job.id} className={editingId === job.id ? 'editing' : ''} tabIndex={0}
|
| 296 |
aria-selected={editingId === job.id}
|
| 297 |
onClick={() => edit(job)} onKeyDown={(event) => editFromKeyboard(event, job)}
|
| 298 |
>
|
| 299 |
-
<td title={job.name}>{job.name}</td>
|
| 300 |
-
<td title={job.agent.name}>{job.agent.name}</td>
|
| 301 |
<td className="cron-type" aria-label={type} title={type}><Logo cli={job.agent.cli} size={14} /></td>
|
| 302 |
<td>{intervalName(job)}</td>
|
| 303 |
<td className={`cron-state ${job.state}`}>{job.state}</td>
|
| 304 |
<td className="cron-dim">{when(job.next, job.schedule.tz, now)}</td>
|
| 305 |
-
<td title={
|
| 306 |
<td onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}><span className="cron-actions">
|
| 307 |
<button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.runCron(job.id))}>Run now</button>
|
| 308 |
<button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.updateCron(job.id, { state: job.state === 'running' ? 'stopped' : 'running' }))}>{job.state === 'running' ? 'Stop' : 'Start'}</button>
|
|
|
|
| 73 |
const delta = value - now;
|
| 74 |
if (delta > 0 && delta < 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 60_000))}m`;
|
| 75 |
if (delta > 0 && delta < 24 * 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 3_600_000))}h`;
|
| 76 |
+
return compactWhen(iso, zone);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
};
|
| 78 |
const compactWhen = (iso: string, zone: string) => {
|
| 79 |
const value = Date.parse(iso);
|
|
|
|
| 286 |
<thead><tr><th>Job</th><th>Agent</th><th>Type</th><th>Interval</th><th>State</th><th>Next</th><th>Last run</th><th>Actions</th></tr></thead>
|
| 287 |
<tbody>{jobs.map((job) => {
|
| 288 |
const type = clis.find((candidate) => candidate.id === job.agent.cli)?.label || job.agent.cli;
|
| 289 |
+
const lastTitle = job.last
|
| 290 |
+
? [duration(job.last.durationMs), job.last.error].filter(Boolean).join(' · ')
|
| 291 |
+
: '';
|
| 292 |
return (
|
| 293 |
<tr
|
| 294 |
key={job.id} className={editingId === job.id ? 'editing' : ''} tabIndex={0}
|
| 295 |
aria-selected={editingId === job.id}
|
| 296 |
onClick={() => edit(job)} onKeyDown={(event) => editFromKeyboard(event, job)}
|
| 297 |
>
|
| 298 |
+
<td className="cron-name" title={job.name}>{job.name}</td>
|
| 299 |
+
<td className="cron-name" title={job.agent.name}>{job.agent.name}</td>
|
| 300 |
<td className="cron-type" aria-label={type} title={type}><Logo cli={job.agent.cli} size={14} /></td>
|
| 301 |
<td>{intervalName(job)}</td>
|
| 302 |
<td className={`cron-state ${job.state}`}>{job.state}</td>
|
| 303 |
<td className="cron-dim">{when(job.next, job.schedule.tz, now)}</td>
|
| 304 |
+
<td title={lastTitle}>{job.last ? <><span className={`cron-last ${job.last.status}`}>{job.last.status}</span> <span className="cron-dim">· {compactWhen(job.last.at, job.schedule.tz)}</span></> : <span className="cron-dim">—</span>}</td>
|
| 305 |
<td onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}><span className="cron-actions">
|
| 306 |
<button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.runCron(job.id))}>Run now</button>
|
| 307 |
<button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.updateCron(job.id, { state: job.state === 'running' ? 'stopped' : 'running' }))}>{job.state === 'running' ? 'Stop' : 'Start'}</button>
|
web/src/styles.css
CHANGED
|
@@ -1432,16 +1432,16 @@ a.btn-ghost { text-decoration: none; }
|
|
| 1432 |
.cron-message { margin-top: 14px; }
|
| 1433 |
.cron-page h3 { margin-top: 24px; }
|
| 1434 |
.cron-table-wrap { margin-top: 8px; border-top: 1px solid var(--border); }
|
| 1435 |
-
.cron-table { width: 100%;
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
-
|
| 1439 |
-
.cron-col-
|
| 1440 |
-
.cron-col-
|
| 1441 |
-
.cron-col-last { width:
|
| 1442 |
-
.cron-
|
| 1443 |
-
.cron-table
|
| 1444 |
-
.cron-table td {
|
| 1445 |
.cron-table tbody tr { cursor: pointer; }
|
| 1446 |
.cron-table tbody tr:hover, .cron-table tbody tr.editing { background: var(--panel-2); }
|
| 1447 |
.cron-table tbody tr:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
|
@@ -1452,7 +1452,7 @@ a.btn-ghost { text-decoration: none; }
|
|
| 1452 |
.cron-state.stopped { color: var(--muted); }
|
| 1453 |
.cron-last.failed { color: var(--danger); }
|
| 1454 |
.cron-actions { display: inline-flex; gap: 4px; white-space: nowrap; }
|
| 1455 |
-
.cron-actions .btn-ghost { padding: 4px
|
| 1456 |
.cron-actions .btn-ghost:disabled { cursor: wait; }
|
| 1457 |
|
| 1458 |
@container (max-width: 560px) {
|
|
|
|
| 1432 |
.cron-message { margin-top: 14px; }
|
| 1433 |
.cron-page h3 { margin-top: 24px; }
|
| 1434 |
.cron-table-wrap { margin-top: 8px; border-top: 1px solid var(--border); }
|
| 1435 |
+
.cron-table { width: 100%; border-collapse: collapse; table-layout: auto; font-size: 11.5px; }
|
| 1436 |
+
/* The two unbounded names share whatever is left. Every other column takes its
|
| 1437 |
+
intrinsic, one-line width, so state/date/action labels cannot be sacrificed
|
| 1438 |
+
to a long name or to widths tuned for one particular viewport. */
|
| 1439 |
+
.cron-col-job, .cron-col-agent { width: 50%; }
|
| 1440 |
+
.cron-col-type, .cron-col-interval, .cron-col-state, .cron-col-next,
|
| 1441 |
+
.cron-col-last, .cron-col-actions { width: 1%; }
|
| 1442 |
+
.cron-table th { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--muted); font: 500 10px var(--font-mono); letter-spacing: 0.045em; text-align: left; text-transform: uppercase; white-space: nowrap; }
|
| 1443 |
+
.cron-table td { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--text); font-family: var(--font-mono); vertical-align: middle; white-space: nowrap; }
|
| 1444 |
+
.cron-table td.cron-name { max-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
| 1445 |
.cron-table tbody tr { cursor: pointer; }
|
| 1446 |
.cron-table tbody tr:hover, .cron-table tbody tr.editing { background: var(--panel-2); }
|
| 1447 |
.cron-table tbody tr:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
|
|
|
| 1452 |
.cron-state.stopped { color: var(--muted); }
|
| 1453 |
.cron-last.failed { color: var(--danger); }
|
| 1454 |
.cron-actions { display: inline-flex; gap: 4px; white-space: nowrap; }
|
| 1455 |
+
.cron-actions .btn-ghost { padding: 4px 5px; border-radius: var(--r-sm); background: var(--panel); font: 500 10.5px var(--font-mono); }
|
| 1456 |
.cron-actions .btn-ghost:disabled { cursor: wait; }
|
| 1457 |
|
| 1458 |
@container (max-width: 560px) {
|
web/test/cronSettings.test.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const stub = path.join(tmp, 'api-stub.ts');
|
|
| 17 |
fs.writeFileSync(stub, `
|
| 18 |
export * from ${JSON.stringify(path.join(WEB, 'src/api.ts'))};
|
| 19 |
let jobs = [{
|
| 20 |
-
id: 'cron_one', name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check.',
|
| 21 |
schedule: { cron: '0 9 * * *', tz: 'Europe/Zurich' }, runOnRestart: true,
|
| 22 |
state: 'running', createdAt: '2026-08-19T00:00:00Z', updatedAt: '2026-08-19T00:00:00Z',
|
| 23 |
next: '2026-08-20T07:00:00Z', last: { at: '2026-08-19T07:00:00Z', status: 'ok', durationMs: 258 },
|
|
@@ -114,10 +114,38 @@ try {
|
|
| 114 |
assert.equal(compactRow.noWrap, true, 'every retained column is pinned to one line');
|
| 115 |
assert.equal(compactRow.typeText, '', 'type column is icon-only');
|
| 116 |
assert.equal(compactRow.interval, 'every day 09:00', 'interval omits timezone and restart detail');
|
| 117 |
-
assert.match(compactRow.last, /^ok
|
| 118 |
assert.equal(compactRow.buttonsFit, true, 'action labels are not clipped');
|
| 119 |
assert.deepEqual(compactRow.buttonBorders, ['solid', 'solid', 'solid'], 'actions use button controls, not text links');
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
await page.getByLabel('Job name', { exact: true }).fill('weekday digest');
|
| 122 |
await page.getByLabel('Agent name', { exact: true }).fill('digest-agent');
|
| 123 |
await page.getByLabel('Prompt', { exact: true }).fill('Summarize yesterday.');
|
|
@@ -136,8 +164,8 @@ try {
|
|
| 136 |
const row = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
|
| 137 |
await row.click();
|
| 138 |
await page.getByRole('button', { name: 'Update job' }).waitFor();
|
| 139 |
-
assert.equal(await page.getByLabel('Job name', { exact: true }).inputValue(), 'morning check');
|
| 140 |
-
assert.equal(await page.getByLabel('Agent name', { exact: true }).inputValue(), 'triage');
|
| 141 |
assert.equal(await page.getByLabel('Prompt', { exact: true }).inputValue(), 'Check.');
|
| 142 |
assert.equal(await page.locator('input[type="time"]').inputValue(), '09:00');
|
| 143 |
assert.equal(await page.getByLabel('timezone').inputValue(), 'Europe/Zurich');
|
|
@@ -157,7 +185,7 @@ try {
|
|
| 157 |
await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].prompt));
|
| 158 |
const edited = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'update' && call[2].prompt));
|
| 159 |
assert.deepEqual(edited, ['update', 'cron_one', {
|
| 160 |
-
name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check and summarize.',
|
| 161 |
schedule: { cron: '15 10 * * 4', tz: 'Asia/Tokyo' }, runOnRestart: false,
|
| 162 |
}], 'row selection round-trips every persisted field through PUT');
|
| 163 |
assert.equal(await page.getByRole('button', { name: 'Create job' }).isVisible(), true, 'successful update returns the form to create mode');
|
|
|
|
| 17 |
fs.writeFileSync(stub, `
|
| 18 |
export * from ${JSON.stringify(path.join(WEB, 'src/api.ts'))};
|
| 19 |
let jobs = [{
|
| 20 |
+
id: 'cron_one', name: 'morning check with a deliberately long job name', agent: { name: 'triage agent with a deliberately long name', cli: 'codex' }, prompt: 'Check.',
|
| 21 |
schedule: { cron: '0 9 * * *', tz: 'Europe/Zurich' }, runOnRestart: true,
|
| 22 |
state: 'running', createdAt: '2026-08-19T00:00:00Z', updatedAt: '2026-08-19T00:00:00Z',
|
| 23 |
next: '2026-08-20T07:00:00Z', last: { at: '2026-08-19T07:00:00Z', status: 'ok', durationMs: 258 },
|
|
|
|
| 114 |
assert.equal(compactRow.noWrap, true, 'every retained column is pinned to one line');
|
| 115 |
assert.equal(compactRow.typeText, '', 'type column is icon-only');
|
| 116 |
assert.equal(compactRow.interval, 'every day 09:00', 'interval omits timezone and restart detail');
|
| 117 |
+
assert.match(compactRow.last, /^ok · 19 Aug 09:00$/, 'last run keeps the complete dense date');
|
| 118 |
assert.equal(compactRow.buttonsFit, true, 'action labels are not clipped');
|
| 119 |
assert.deepEqual(compactRow.buttonBorders, ['solid', 'solid', 'solid'], 'actions use button controls, not text links');
|
| 120 |
|
| 121 |
+
const widths = [];
|
| 122 |
+
for (const width of [1200, 980, 760, 390]) {
|
| 123 |
+
await page.setViewportSize({ width, height: 900 });
|
| 124 |
+
widths.push(await firstRow.evaluate((row) => {
|
| 125 |
+
const wrap = row.closest('.cron-table-wrap');
|
| 126 |
+
const cells = [...row.querySelectorAll('td')];
|
| 127 |
+
const buttons = [...row.querySelectorAll('button')];
|
| 128 |
+
return {
|
| 129 |
+
width: window.innerWidth,
|
| 130 |
+
overflow: wrap.scrollWidth - wrap.clientWidth,
|
| 131 |
+
clipped: cells.map((cell) => cell.scrollWidth > cell.clientWidth),
|
| 132 |
+
actionFits: buttons.every((button) => button.scrollWidth <= button.clientWidth)
|
| 133 |
+
&& cells[7].scrollWidth <= cells[7].clientWidth,
|
| 134 |
+
};
|
| 135 |
+
}));
|
| 136 |
+
}
|
| 137 |
+
for (const layout of widths.slice(0, 2)) {
|
| 138 |
+
assert.equal(layout.overflow, 0, `${layout.width}px viewport needs no table scrollbar`);
|
| 139 |
+
}
|
| 140 |
+
for (const layout of widths) {
|
| 141 |
+
assert.deepEqual(layout.clipped.slice(2), [false, false, false, false, false, false],
|
| 142 |
+
`${layout.width}px keeps every bounded column intact`);
|
| 143 |
+
assert.equal(layout.actionFits, true, `${layout.width}px keeps every action usable`);
|
| 144 |
+
}
|
| 145 |
+
assert.equal(widths.at(-1).overflow > 0, true, 'the genuinely narrow table owns its overflow');
|
| 146 |
+
assert.equal(widths.at(-1).clipped.slice(0, 2).some(Boolean), true,
|
| 147 |
+
'at the narrow limit a name gives before a bounded column or action');
|
| 148 |
+
|
| 149 |
await page.getByLabel('Job name', { exact: true }).fill('weekday digest');
|
| 150 |
await page.getByLabel('Agent name', { exact: true }).fill('digest-agent');
|
| 151 |
await page.getByLabel('Prompt', { exact: true }).fill('Summarize yesterday.');
|
|
|
|
| 164 |
const row = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
|
| 165 |
await row.click();
|
| 166 |
await page.getByRole('button', { name: 'Update job' }).waitFor();
|
| 167 |
+
assert.equal(await page.getByLabel('Job name', { exact: true }).inputValue(), 'morning check with a deliberately long job name');
|
| 168 |
+
assert.equal(await page.getByLabel('Agent name', { exact: true }).inputValue(), 'triage agent with a deliberately long name');
|
| 169 |
assert.equal(await page.getByLabel('Prompt', { exact: true }).inputValue(), 'Check.');
|
| 170 |
assert.equal(await page.locator('input[type="time"]').inputValue(), '09:00');
|
| 171 |
assert.equal(await page.getByLabel('timezone').inputValue(), 'Europe/Zurich');
|
|
|
|
| 185 |
await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].prompt));
|
| 186 |
const edited = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'update' && call[2].prompt));
|
| 187 |
assert.deepEqual(edited, ['update', 'cron_one', {
|
| 188 |
+
name: 'morning check with a deliberately long job name', agent: { name: 'triage agent with a deliberately long name', cli: 'codex' }, prompt: 'Check and summarize.',
|
| 189 |
schedule: { cron: '15 10 * * 4', tz: 'Asia/Tokyo' }, runOnRestart: false,
|
| 190 |
}], 'row selection round-trips every persisted field through PUT');
|
| 191 |
assert.equal(await page.getByRole('button', { name: 'Create job' }).isVisible(), true, 'successful update returns the form to create mode');
|