@@ -4,9 +4,19 @@ import { useAppToast } from "@/components/providers";
44import { Button } from "@/components/ui/button" ;
55import { useBackfillActionMutation } from "@/lib/api/hooks" ;
66import { startSyncWatch } from "@/lib/sync-watch" ;
7- import { AlertCircle , CheckCircle2 , Clock3 , Loader2 , RefreshCw , Trash2 , XCircle } from "lucide-react" ;
7+ import {
8+ AlertCircle ,
9+ CheckCircle2 ,
10+ ChevronDown ,
11+ ChevronRight ,
12+ Clock3 ,
13+ Loader2 ,
14+ RefreshCw ,
15+ Trash2 ,
16+ XCircle ,
17+ } from "lucide-react" ;
818import { useRouter } from "next/navigation" ;
9- import { useState } from "react" ;
19+ import { useMemo , useState } from "react" ;
1020
1121export type BackfillJobRow = {
1222 id : string ;
@@ -22,13 +32,76 @@ type Props = {
2232 jobs : BackfillJobRow [ ] ;
2333} ;
2434
35+ type BackfillGroup = {
36+ key : string ;
37+ year : number ;
38+ provider : BackfillJobRow [ "provider" ] ;
39+ jobs : BackfillJobRow [ ] ;
40+ queuedCount : number ;
41+ runningCount : number ;
42+ completedCount : number ;
43+ failedCount : number ;
44+ status : BackfillJobRow [ "status" ] ;
45+ latestError : string | null ;
46+ } ;
47+
2548export function BackfillJobsPanel ( { jobs } : Props ) {
2649 const router = useRouter ( ) ;
2750 const { pushToast } = useAppToast ( ) ;
2851 const retryMutation = useBackfillActionMutation ( "retry" ) ;
2952 const deleteMutation = useBackfillActionMutation ( "delete" ) ;
3053 const cleanupMutation = useBackfillActionMutation ( "cleanup" ) ;
3154 const [ busyAction , setBusyAction ] = useState < string | null > ( null ) ;
55+ const [ expandedGroups , setExpandedGroups ] = useState < Record < string , boolean > > ( { } ) ;
56+
57+ const groups = useMemo < BackfillGroup [ ] > ( ( ) => {
58+ const map = new Map < string , BackfillGroup > ( ) ;
59+ for ( const job of jobs ) {
60+ const key = `${ job . year } :${ job . provider ?? "ALL" } ` ;
61+ const existing = map . get ( key ) ;
62+ if ( ! existing ) {
63+ map . set ( key , {
64+ key,
65+ year : job . year ,
66+ provider : job . provider ,
67+ jobs : [ job ] ,
68+ queuedCount : job . status === "queued" ? 1 : 0 ,
69+ runningCount : job . status === "running" ? 1 : 0 ,
70+ completedCount : job . status === "completed" ? 1 : 0 ,
71+ failedCount : job . status === "failed" ? 1 : 0 ,
72+ status : job . status ,
73+ latestError : job . status === "failed" ? job . errorMessage : null ,
74+ } ) ;
75+ continue ;
76+ }
77+
78+ existing . jobs . push ( job ) ;
79+ if ( job . status === "queued" ) existing . queuedCount += 1 ;
80+ if ( job . status === "running" ) existing . runningCount += 1 ;
81+ if ( job . status === "completed" ) existing . completedCount += 1 ;
82+ if ( job . status === "failed" ) {
83+ existing . failedCount += 1 ;
84+ if ( ! existing . latestError && job . errorMessage ) existing . latestError = job . errorMessage ;
85+ }
86+ }
87+
88+ const list = Array . from ( map . values ( ) ) . map ( ( group ) => {
89+ const sortedJobs = [ ...group . jobs ] . sort ( ( a , b ) => {
90+ const rank = { running : 0 , queued : 1 , failed : 2 , completed : 3 } as const ;
91+ return rank [ a . status ] - rank [ b . status ] ;
92+ } ) ;
93+ let status : BackfillJobRow [ "status" ] = "completed" ;
94+ if ( group . runningCount > 0 ) status = "running" ;
95+ else if ( group . queuedCount > 0 ) status = "queued" ;
96+ else if ( group . failedCount > 0 ) status = "failed" ;
97+ return { ...group , jobs : sortedJobs , status } ;
98+ } ) ;
99+
100+ return list . sort ( ( a , b ) => {
101+ if ( a . year !== b . year ) return b . year - a . year ;
102+ return formatProvider ( a . provider ) . localeCompare ( formatProvider ( b . provider ) ) ;
103+ } ) ;
104+ } , [ jobs ] ) ;
32105
33106 async function postForm ( action : "retry" | "delete" | "cleanup" , jobId ?: string ) {
34107 const actionKey = `${ action } :${ jobId ?? "all" } ` ;
@@ -53,9 +126,40 @@ export function BackfillJobsPanel({ jobs }: Props) {
53126 router . refresh ( ) ;
54127 }
55128
129+ async function runGroupAction ( action : "retry-group" | "delete-group" , group : BackfillGroup ) {
130+ const key = `${ action } :${ group . key } ` ;
131+ setBusyAction ( key ) ;
132+
133+ try {
134+ if ( action === "retry-group" ) {
135+ const failedJobs = group . jobs . filter ( ( job ) => job . status === "failed" ) ;
136+ await Promise . all ( failedJobs . map ( ( job ) => retryMutation . mutateAsync ( job . id ) ) ) ;
137+ if ( failedJobs . length > 0 ) {
138+ startSyncWatch ( ) ;
139+ pushToast (
140+ { title : "Backfill retries queued" , subtitle : `${ failedJobs . length } failed chunk${ failedJobs . length > 1 ? "s" : "" } requeued.` } ,
141+ "info" ,
142+ ) ;
143+ }
144+ } else {
145+ const deletableJobs = group . jobs . filter ( ( job ) => job . status !== "running" ) ;
146+ await Promise . all ( deletableJobs . map ( ( job ) => deleteMutation . mutateAsync ( job . id ) ) ) ;
147+ pushToast (
148+ { title : "Backfill group cleaned" , subtitle : `${ deletableJobs . length } chunk${ deletableJobs . length > 1 ? "s" : "" } removed.` } ,
149+ "success" ,
150+ ) ;
151+ }
152+ } catch {
153+ pushToast ( { title : "Action failed" , subtitle : "Please try again." } , "error" ) ;
154+ } finally {
155+ setBusyAction ( null ) ;
156+ router . refresh ( ) ;
157+ }
158+ }
159+
56160 return (
57161 < div className = "mt-4 space-y-2" >
58- { jobs . length > 0 ? (
162+ { groups . length > 0 ? (
59163 < div className = "mb-2 flex justify-end" >
60164 < Button
61165 size = "sm"
@@ -64,61 +168,115 @@ export function BackfillJobsPanel({ jobs }: Props) {
64168 disabled = { busyAction === "cleanup:all" }
65169 onClick = { ( ) => postForm ( "cleanup" ) }
66170 >
67- { busyAction === "cleanup:all" ? < span className = "flex gap-1" > < Loader2 className = "size-4 animate-spin" /> Cleaning...</ span > : < span className = "flex gap-1" > < Trash2 /> Clean completed</ span > }
171+ { busyAction === "cleanup:all" ? (
172+ < span className = "flex gap-1" >
173+ < Loader2 className = "size-4 animate-spin" /> Cleaning...
174+ </ span >
175+ ) : (
176+ < span className = "flex gap-1" >
177+ < Trash2 />
178+ Clean completed
179+ </ span >
180+ ) }
68181 </ Button >
69182 </ div >
70183 ) : null }
71184
72- { jobs . map ( ( job ) => (
73- < div key = { job . id } className = "flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-sm" >
74- < div className = "flex items-center gap-2" >
75- { renderBackfillStatusIcon ( job . status ) }
76- < span className = "font-medium" > { job . year } </ span >
77- < span className = "text-muted-foreground" > • { formatProvider ( job . provider ) } </ span >
78- < span className = "text-muted-foreground" > • { formatBackfillStatus ( job . status ) } </ span >
79- < span className = "text-muted-foreground" > • attempt { job . attemptCount } </ span >
80- </ div >
81- < div className = "flex items-center gap-2" >
82- { job . status === "failed" && job . errorMessage ? (
83- < span className = "max-w-[480px] truncate text-xs text-red-600" title = { job . errorMessage } >
84- { job . errorMessage }
85- </ span >
86- ) : null }
87- { job . status === "failed" ? (
88- < Button
89- size = "sm"
90- variant = "outline"
91- className = "gap-1"
185+ { groups . map ( ( group ) => {
186+ const expanded = Boolean ( expandedGroups [ group . key ] ) ;
187+ const canRetryFailed = group . failedCount > 0 ;
188+ const deletableCount = group . jobs . filter ( ( job ) => job . status !== "running" ) . length ;
189+
190+ return (
191+ < div key = { group . key } className = "rounded-md border border-border/60 bg-muted/20" >
192+ < div className = "flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm" >
193+ < button
92194 type = "button"
93- disabled = { busyAction === `retry: ${ job . id } ` }
94- onClick = { ( ) => postForm ( "retry" , job . id ) }
195+ className = "inline-flex items-center gap-2 text-left"
196+ onClick = { ( ) => setExpandedGroups ( ( prev ) => ( { ... prev , [ group . key ] : ! expanded } ) ) }
95197 >
96- < RefreshCw className = { busyAction === `retry:${ job . id } ` ? "size-4 animate-spin" : "size-4" } />
97- Retry
98- </ Button >
198+ { expanded ? < ChevronDown className = "size-4 text-muted-foreground" /> : < ChevronRight className = "size-4 text-muted-foreground" /> }
199+ { renderBackfillStatusIcon ( group . status ) }
200+ < span className = "font-medium" > { group . year } </ span >
201+ < span className = "text-muted-foreground" > • { formatProvider ( group . provider ) } </ span >
202+ < span className = "text-muted-foreground" > • { group . completedCount } /{ group . jobs . length } completed</ span >
203+ { group . runningCount > 0 ? < span className = "text-blue-600" > • { group . runningCount } running</ span > : null }
204+ { group . queuedCount > 0 ? < span className = "text-amber-600" > • { group . queuedCount } queued</ span > : null }
205+ { group . failedCount > 0 ? < span className = "text-red-600" > • { group . failedCount } failed</ span > : null }
206+ </ button >
207+ < div className = "flex items-center gap-2" >
208+ { canRetryFailed ? (
209+ < Button
210+ size = "sm"
211+ variant = "outline"
212+ type = "button"
213+ disabled = { busyAction === `retry-group:${ group . key } ` }
214+ onClick = { ( ) => runGroupAction ( "retry-group" , group ) }
215+ className = "gap-1"
216+ >
217+ < RefreshCw className = { busyAction === `retry-group:${ group . key } ` ? "size-4 animate-spin" : "size-4" } />
218+ Retry failed
219+ </ Button >
220+ ) : null }
221+ < Button
222+ size = "icon"
223+ variant = "destructive"
224+ type = "button"
225+ disabled = { deletableCount === 0 || busyAction === `delete-group:${ group . key } ` }
226+ onClick = { ( ) => runGroupAction ( "delete-group" , group ) }
227+ >
228+ { busyAction === `delete-group:${ group . key } ` ? < Loader2 className = "size-4 animate-spin" /> : < Trash2 className = "size-4" /> }
229+ </ Button >
230+ </ div >
231+ </ div >
232+ { expanded ? (
233+ < div className = "space-y-2 border-t border-border/60 px-3 py-2" >
234+ { group . jobs . map ( ( job ) => (
235+ < div key = { job . id } className = "flex flex-wrap items-center justify-between gap-2 rounded-md border border-border/50 bg-background/50 px-2 py-1.5 text-xs" >
236+ < div className = "flex items-center gap-2" >
237+ { renderBackfillStatusIcon ( job . status ) }
238+ < span className = "text-muted-foreground" > { formatBackfillStatus ( job . status ) } </ span >
239+ < span className = "text-muted-foreground" > • attempt { job . attemptCount } </ span >
240+ { job . status === "failed" && job . errorMessage ? (
241+ < span className = "max-w-[480px] truncate text-red-600" title = { job . errorMessage } >
242+ • { job . errorMessage }
243+ </ span >
244+ ) : null }
245+ </ div >
246+ < div className = "flex items-center gap-2" >
247+ { job . status === "failed" ? (
248+ < Button
249+ size = "sm"
250+ variant = "outline"
251+ type = "button"
252+ className = "h-7 gap-1 px-2 text-xs"
253+ disabled = { busyAction === `retry:${ job . id } ` }
254+ onClick = { ( ) => postForm ( "retry" , job . id ) }
255+ >
256+ < RefreshCw className = { busyAction === `retry:${ job . id } ` ? "size-3 animate-spin" : "size-3" } />
257+ Retry
258+ </ Button >
259+ ) : null }
260+ < Button
261+ size = "icon"
262+ variant = "destructive"
263+ className = "h-7 w-7"
264+ type = "button"
265+ disabled = { job . status === "running" || busyAction === `delete:${ job . id } ` }
266+ onClick = { ( ) => postForm ( "delete" , job . id ) }
267+ >
268+ { busyAction === `delete:${ job . id } ` ? < Loader2 className = "size-3 animate-spin" /> : < Trash2 className = "size-3" /> }
269+ </ Button >
270+ </ div >
271+ </ div >
272+ ) ) }
273+ { group . latestError ? < p className = "text-xs text-red-600" > { group . latestError } </ p > : null }
274+ </ div >
99275 ) : null }
100- < Button
101- size = "icon"
102- variant = "destructive"
103- className = "gap-1"
104- type = "button"
105- disabled = { busyAction === `delete:${ job . id } ` }
106- onClick = { ( ) => postForm ( "delete" , job . id ) }
107- >
108- { busyAction === `delete:${ job . id } ` ? (
109- < >
110- < Loader2 className = "size-4 animate-spin" />
111- </ >
112- ) : (
113- < >
114- < Trash2 className = "size-4" />
115- </ >
116- ) }
117- </ Button >
118276 </ div >
119- </ div >
120- ) ) }
121- { jobs . length === 0 ? < p className = "text-sm text-muted-foreground" > No historical backfill jobs yet.</ p > : null }
277+ ) ;
278+ } ) }
279+ { groups . length === 0 ? < p className = "text-sm text-muted-foreground" > No historical backfill jobs yet.</ p > : null }
122280 </ div >
123281 ) ;
124282}
0 commit comments