Skip to content

Commit 38963ab

Browse files
committed
Merge: runs group-by-job + load-more
2 parents a09ae3d + 7a3799f commit 38963ab

7 files changed

Lines changed: 191 additions & 100 deletions

File tree

FOLLOWUPS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,11 @@ Cronicle's foundation (Node runtime, bespoke flat-file storage) is weaker than a
303303
- `[PLANNED]` Retention: a settings control for the window (`run_retention_days` /
304304
`prune_interval_secs`) and a manual "prune now" action. Backend is ready:
305305
`POST /api/v1/runs/prune?older_than_days=N` returns the count pruned.
306-
- `[PLANNED]` Dashboard: runs grouped by job, plus load-more/pagination and incremental
307-
polling (only fetch new/updated runs).
306+
- `[DONE]` Runs page: a "Group by job" toggle (collapsible per-job tables with counts) and
307+
a "Load more" button that grows the fetched window. Growing the window (rather than
308+
accumulating pages) keeps every shown run's state live under polling. `[PLANNED]` true
309+
incremental polling (fetch only new/changed) needs an `updated_at` cursor on runs - the
310+
current `scheduled_for` cursor would miss state changes to already-shown runs.
308311
- `[DONE]` Tenant management UI (`web-ui` `/tenants`, admin-only nav): list tenants;
309312
system admins create them. `[PLANNED]` Tenant context/picker (needs a backend
310313
scope-override; today listings derive scope from the JWT only).

ui_dist/assets/index-BZkqKtOk.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui_dist/assets/index-CSObYWZ2.css

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 52 additions & 47 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui_dist/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Nexus Arbiter</title>
8-
<script type="module" crossorigin src="/assets/index-Bn86hG2x.js"></script>
9-
<link rel="stylesheet" crossorigin href="/assets/index-CSObYWZ2.css">
8+
<script type="module" crossorigin src="/assets/index-CSZCiq63.js"></script>
9+
<link rel="stylesheet" crossorigin href="/assets/index-BZkqKtOk.css">
1010
</head>
1111
<body>
1212
<div id="root"></div>

web-ui/src/pages/RunsPage.tsx

Lines changed: 130 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Fragment, useMemo, useState } from 'react'
22
import { useRuns } from '../hooks/useRuns'
33
import { SlideOver } from '../components/SlideOver'
44
import type { JobRun } from '../backend-types/JobRun'
5+
import type { JobSpec } from '../backend-types/JobSpec'
56
import { RunDetail } from './RunDetail'
67
import { useJobs } from '../hooks/useJobs'
78
import type { ListRunsQuery } from '../backend-types'
@@ -90,18 +91,21 @@ export function PollSelect({
9091
)
9192
}
9293

94+
const PAGE = 100
95+
9396
export function RunsPage() {
9497
const [selectedId, setSelectedId] = useState<string | null>(null)
9598
const [filterJobId, setFilterJobId] = useState<string | undefined>(undefined)
9699
const [filterWorkerId, setFilterWorkerId] = useState<string | undefined>(
97100
undefined
98101
)
99102
const [pollMs, setPollMs] = useState(2000)
103+
const [limit, setLimit] = useState(PAGE)
104+
const [groupByJob, setGroupByJob] = useState(false)
100105

101-
// TODO: Option to not remove older runs from display, only add news ones. Try to not keep scrolling past where the user scrolled
102-
// TODO: Load more button at the bottom to see more history
103-
// TODO: Potential from/to date filters (and no refresh for them if "to" is past "now" maybe?)
104-
// TODO: Or maybe just pick a scheduled for time a bit before loading, and just query ones from then on
106+
// "Load more" grows the window rather than accumulating pages, so polling keeps every
107+
// shown run's state live (a queued -> running -> succeeded transition is reflected).
108+
// TODO: true incremental polling (fetch only new/changed) needs an updated_at cursor.
105109

106110
const makeQuery = () => {
107111
const q: ListRunsQuery = {}
@@ -112,7 +116,7 @@ export function RunsPage() {
112116
q.byWorkerId = filterWorkerId
113117
}
114118

115-
q.limit = 100
119+
q.limit = limit
116120

117121
return q
118122
}
@@ -129,7 +133,7 @@ export function RunsPage() {
129133
// const getJob = (job_id: string) => jobs?.find((job) => job.id === job_id)
130134

131135
const jobsMap = useMemo(() => {
132-
const m = new Map()
136+
const m = new Map<string, JobSpec>()
133137
jobs?.forEach((j) => m.set(j.id, j))
134138
return m
135139
}, [jobs])
@@ -191,50 +195,38 @@ export function RunsPage() {
191195
)}
192196
</div>
193197
</div>
198+
199+
{/* Group by job */}
200+
<label className="flex items-center gap-2 text-sm text-(--text-primary) pb-2">
201+
<input
202+
type="checkbox"
203+
checked={groupByJob}
204+
onChange={(e) => setGroupByJob(e.target.checked)}
205+
/>
206+
Group by job
207+
</label>
194208
</div>
195209

196-
{runs && (
197-
<div className="rounded-lg shadow border border-(--border-color) overflow-hidden bg-(--bg-surface-alt)">
198-
<table className="w-full text-left">
199-
<thead className="bg-(--bg-header) border-b border-(--border-subtle)">
200-
<tr>
201-
<th className="px-4 py-2 font-semibold">Job</th>
202-
<th className="px-4 py-2 font-semibold">State</th>
203-
<th className="px-4 py-2 font-semibold">Started</th>
204-
<th className="px-4 py-2 font-semibold">Finished</th>
205-
<th className="px-4 py-2 font-semibold">Scheduled for</th>
206-
</tr>
207-
</thead>
210+
{runs &&
211+
(groupByJob ? (
212+
<GroupedRuns runs={runs} jobsMap={jobsMap} onSelect={setSelectedId} />
213+
) : (
214+
<RunsTable runs={runs} jobsMap={jobsMap} onSelect={setSelectedId} showJob />
215+
))}
208216

209-
<tbody className="divide-y divide-(--border-subtle)">
210-
{runs.map((run) => (
211-
<tr
212-
key={run.id}
213-
className="hover:bg-(--bg-row-hover) cursor-pointer"
214-
onClick={() => setSelectedId(run.id)}
215-
>
216-
<td className="px-4 py-2">
217-
<div>
218-
<span>
219-
{jobsMap.get(run.jobId)?.name ?? '<Unknown Job>'}
220-
</span>
221-
<div className="text-xs text-gray-500">
222-
Worker: {run.workerId ?? '—'}
223-
</div>
224-
</div>
225-
</td>
226-
227-
<td className="px-4 py-2">
228-
<RunStateBadge state={run.state} runId={run.id} />
229-
</td>
230-
<td className="px-4 py-2">{formatTime(run.startedAt)}</td>
231-
<td className="px-4 py-2">{formatTime(run.finishedAt)}</td>
232-
<td className="px-4 py-2">{formatTime(run.scheduledFor)}</td>
233-
</tr>
234-
))}
235-
</tbody>
236-
</table>
237-
</div>
217+
{runs && runs.length >= limit && (
218+
<button
219+
type="button"
220+
onClick={() => setLimit((l) => l + PAGE)}
221+
className="
222+
px-4 py-2 rounded text-sm font-medium
223+
bg-(--bg-btn-secondary) text-(--text-primary)
224+
border border-(--border-subtle)
225+
hover:bg-(--bg-btn-secondary-hover)
226+
"
227+
>
228+
Load more
229+
</button>
238230
)}
239231

240232
<SlideOver
@@ -248,6 +240,97 @@ export function RunsPage() {
248240
)
249241
}
250242

243+
function RunsTable({
244+
runs,
245+
jobsMap,
246+
onSelect,
247+
showJob = false,
248+
}: {
249+
runs: JobRun[]
250+
jobsMap: Map<string, JobSpec>
251+
onSelect: (id: string) => void
252+
showJob?: boolean
253+
}) {
254+
return (
255+
<div className="rounded-lg shadow border border-(--border-color) overflow-hidden bg-(--bg-surface-alt)">
256+
<table className="w-full text-left">
257+
<thead className="bg-(--bg-header) border-b border-(--border-subtle)">
258+
<tr>
259+
{showJob && <th className="px-4 py-2 font-semibold">Job</th>}
260+
<th className="px-4 py-2 font-semibold">State</th>
261+
<th className="px-4 py-2 font-semibold">Started</th>
262+
<th className="px-4 py-2 font-semibold">Finished</th>
263+
<th className="px-4 py-2 font-semibold">Scheduled for</th>
264+
</tr>
265+
</thead>
266+
<tbody className="divide-y divide-(--border-subtle)">
267+
{runs.map((run) => (
268+
<tr
269+
key={run.id}
270+
className="hover:bg-(--bg-row-hover) cursor-pointer"
271+
onClick={() => onSelect(run.id)}
272+
>
273+
{showJob && (
274+
<td className="px-4 py-2">
275+
<div>
276+
<span>{jobsMap.get(run.jobId)?.name ?? '<Unknown Job>'}</span>
277+
<div className="text-xs text-(--text-muted)">
278+
Worker: {run.workerId ?? '—'}
279+
</div>
280+
</div>
281+
</td>
282+
)}
283+
<td className="px-4 py-2">
284+
<RunStateBadge state={run.state} runId={run.id} />
285+
</td>
286+
<td className="px-4 py-2">{formatTime(run.startedAt)}</td>
287+
<td className="px-4 py-2">{formatTime(run.finishedAt)}</td>
288+
<td className="px-4 py-2">{formatTime(run.scheduledFor)}</td>
289+
</tr>
290+
))}
291+
</tbody>
292+
</table>
293+
</div>
294+
)
295+
}
296+
297+
function GroupedRuns({
298+
runs,
299+
jobsMap,
300+
onSelect,
301+
}: {
302+
runs: JobRun[]
303+
jobsMap: Map<string, JobSpec>
304+
onSelect: (id: string) => void
305+
}) {
306+
const groups = useMemo(() => {
307+
const m = new Map<string, JobRun[]>()
308+
for (const r of runs) {
309+
const arr = m.get(r.jobId) ?? []
310+
arr.push(r)
311+
m.set(r.jobId, arr)
312+
}
313+
const name = (id: string) => jobsMap.get(id)?.name ?? id
314+
return [...m.entries()].sort((a, b) => name(a[0]).localeCompare(name(b[0])))
315+
}, [runs, jobsMap])
316+
317+
return (
318+
<div className="space-y-6">
319+
{groups.map(([jobId, jobRuns]) => (
320+
<div key={jobId} className="space-y-2">
321+
<h3 className="text-sm font-semibold text-(--text-primary)">
322+
{jobsMap.get(jobId)?.name ?? '<Unknown Job>'}{' '}
323+
<span className="text-(--text-muted) font-normal">
324+
({jobRuns.length})
325+
</span>
326+
</h3>
327+
<RunsTable runs={jobRuns} jobsMap={jobsMap} onSelect={onSelect} />
328+
</div>
329+
))}
330+
</div>
331+
)
332+
}
333+
251334
function formatTime(t?: string | null) {
252335
if (!t) return '—'
253336
return new Date(t).toLocaleString()

0 commit comments

Comments
 (0)