@@ -2,6 +2,7 @@ import { Fragment, useMemo, useState } from 'react'
22import { useRuns } from '../hooks/useRuns'
33import { SlideOver } from '../components/SlideOver'
44import type { JobRun } from '../backend-types/JobRun'
5+ import type { JobSpec } from '../backend-types/JobSpec'
56import { RunDetail } from './RunDetail'
67import { useJobs } from '../hooks/useJobs'
78import type { ListRunsQuery } from '../backend-types'
@@ -90,18 +91,21 @@ export function PollSelect({
9091 )
9192}
9293
94+ const PAGE = 100
95+
9396export 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+
251334function formatTime ( t ?: string | null ) {
252335 if ( ! t ) return '—'
253336 return new Date ( t ) . toLocaleString ( )
0 commit comments