1- import { useEffect , useState } from "react" ;
1+ import { useEffect , useRef , useState } from "react" ;
22import { toast } from "sonner" ;
33import type { QueueJob } from "@/server/db/schema" ;
44import { ConfirmQueueActionDialog } from "./ConfirmQueueActionDialog" ;
@@ -14,13 +14,47 @@ interface JobDetailLiveProps {
1414 initialJob : QueueJob ;
1515}
1616
17+ interface JobLogChunkEvent {
18+ jobId : string ;
19+ offset : number ;
20+ nextOffset : number ;
21+ text : string ;
22+ truncated : boolean ;
23+ }
24+
25+ interface ParsedJobLogLine {
26+ timestamp : string ;
27+ level : string ;
28+ message : string ;
29+ }
30+
31+ function parseJobLogChunk ( text : string ) : ParsedJobLogLine [ ] {
32+ return text
33+ . split ( "\n" )
34+ . filter ( ( line ) => line . trim ( ) . length > 0 )
35+ . map ( ( line ) => {
36+ const [ timestamp = "" , level = "info" , ...messageParts ] =
37+ line . split ( "\t" ) ;
38+ const message = messageParts . join ( "\t" ) . trim ( ) ;
39+ return {
40+ timestamp,
41+ level,
42+ message : message || line ,
43+ } ;
44+ } ) ;
45+ }
46+
1747export function JobDetailLive ( { initialJob } : JobDetailLiveProps ) {
1848 const [ job , setJob ] = useState < QueueJob > ( initialJob ) ;
1949 const [ dialogOpen , setDialogOpen ] = useState ( false ) ;
2050 const [ pendingAction , setPendingAction ] = useState <
2151 "cancel" | "forceUnlock" | null
2252 > ( null ) ;
2353 const [ isLoading , setIsLoading ] = useState ( false ) ;
54+ const [ jobLogs , setJobLogs ] = useState < ParsedJobLogLine [ ] > ( [ ] ) ;
55+ const [ logsTruncated , setLogsTruncated ] = useState ( false ) ;
56+ const [ logsConnected , setLogsConnected ] = useState ( false ) ;
57+ const logsContainerRef = useRef < HTMLDivElement > ( null ) ;
2458
2559 useEffect ( ( ) => {
2660 const eventSource = new EventSource (
@@ -46,6 +80,81 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) {
4680 } ;
4781 } , [ initialJob . id ] ) ;
4882
83+ useEffect ( ( ) => {
84+ let eventSource : EventSource | null = null ;
85+ let reconnectTimer : ReturnType < typeof setTimeout > | null = null ;
86+ let isUnmounted = false ;
87+ let nextOffset = 0 ;
88+
89+ setJobLogs ( [ ] ) ;
90+ setLogsTruncated ( false ) ;
91+
92+ const connect = ( ) => {
93+ if ( isUnmounted ) return ;
94+
95+ const params = new URLSearchParams ( { jobId : initialJob . id } ) ;
96+ if ( nextOffset > 0 ) {
97+ params . set ( "offset" , String ( nextOffset ) ) ;
98+ }
99+
100+ eventSource = new EventSource (
101+ `/api/queue/job-logs-stream?${ params . toString ( ) } ` ,
102+ ) ;
103+ eventSource . addEventListener ( "open" , ( ) => {
104+ setLogsConnected ( true ) ;
105+ } ) ;
106+
107+ eventSource . addEventListener ( "log.chunk" , ( event ) => {
108+ try {
109+ const data = JSON . parse ( event . data ) as JobLogChunkEvent ;
110+ nextOffset = data . nextOffset ;
111+
112+ if ( data . truncated ) {
113+ setLogsTruncated ( true ) ;
114+ }
115+
116+ if ( ! data . text ) {
117+ return ;
118+ }
119+
120+ const newLines = parseJobLogChunk ( data . text ) ;
121+ setJobLogs ( ( prev ) => {
122+ const merged = [ ...prev , ...newLines ] ;
123+ const MAX_LOG_LINES = 1_000 ;
124+ return merged . length > MAX_LOG_LINES
125+ ? merged . slice ( - MAX_LOG_LINES )
126+ : merged ;
127+ } ) ;
128+ requestAnimationFrame ( ( ) => {
129+ if ( ! logsContainerRef . current ) return ;
130+ logsContainerRef . current . scrollTop =
131+ logsContainerRef . current . scrollHeight ;
132+ } ) ;
133+ } catch {
134+ toast . error ( "Failed to parse job log stream data" ) ;
135+ }
136+ } ) ;
137+
138+ eventSource . addEventListener ( "error" , ( ) => {
139+ setLogsConnected ( false ) ;
140+ eventSource ?. close ( ) ;
141+ eventSource = null ;
142+ if ( ! isUnmounted ) {
143+ reconnectTimer = setTimeout ( connect , 1_000 ) ;
144+ }
145+ } ) ;
146+ } ;
147+
148+ connect ( ) ;
149+
150+ return ( ) => {
151+ isUnmounted = true ;
152+ setLogsConnected ( false ) ;
153+ if ( reconnectTimer ) clearTimeout ( reconnectTimer ) ;
154+ eventSource ?. close ( ) ;
155+ } ;
156+ } , [ initialJob . id ] ) ;
157+
49158 const canCancel = job . state === "queued" || job . state === "running" ;
50159 const canRunNow = job . state === "queued" ;
51160 const canForceUnlock = job . state === "running" ;
@@ -138,6 +247,7 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) {
138247 </ a >
139248 { canCancel && (
140249 < button
250+ type = "button"
141251 onClick = { ( ) => handleAction ( "cancel" ) }
142252 className = "rounded-md border px-3 py-2 text-sm hover:bg-muted"
143253 >
@@ -146,6 +256,7 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) {
146256 ) }
147257 { canRunNow && (
148258 < button
259+ type = "button"
149260 onClick = { ( ) => handleAction ( "runNow" ) }
150261 className = "rounded-md border px-3 py-2 text-sm hover:bg-muted"
151262 >
@@ -154,6 +265,7 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) {
154265 ) }
155266 { canRetry && (
156267 < button
268+ type = "button"
157269 onClick = { ( ) => handleAction ( "retry" ) }
158270 className = "rounded-md border px-3 py-2 text-sm hover:bg-muted"
159271 >
@@ -162,6 +274,7 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) {
162274 ) }
163275 { canForceUnlock && (
164276 < button
277+ type = "button"
165278 onClick = { ( ) => handleAction ( "forceUnlock" ) }
166279 className = "rounded-md border px-3 py-2 text-sm hover:bg-muted"
167280 >
@@ -282,6 +395,53 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) {
282395 error = { job . lastError }
283396 onRetry = { ( ) => handleAction ( "retry" ) }
284397 />
398+
399+ < div className = "mt-6 rounded-lg border p-4" >
400+ < div className = "mb-2 flex items-center justify-between gap-3" >
401+ < h2 className = "font-semibold" > Logs</ h2 >
402+ < div className = "text-xs text-muted-foreground" >
403+ { logsConnected ? "Live" : "Reconnecting..." }
404+ </ div >
405+ </ div >
406+ { logsTruncated && (
407+ < div className = "mb-2 text-xs text-muted-foreground" >
408+ Showing the latest log tail.
409+ </ div >
410+ ) }
411+ < div
412+ ref = { logsContainerRef }
413+ className = "max-h-96 overflow-y-auto rounded-md border bg-black p-3 font-mono text-xs"
414+ >
415+ { jobLogs . length === 0 ? (
416+ < div className = "text-gray-400" > No logs yet...</ div >
417+ ) : (
418+ jobLogs . map ( ( line , index ) => {
419+ const level = line . level . toLowerCase ( ) ;
420+ const isError = level === "error" || level === "fatal" ;
421+ const colorClass = isError
422+ ? "text-status-error"
423+ : level === "warn"
424+ ? "text-yellow-300"
425+ : "text-status-success" ;
426+
427+ return (
428+ < div
429+ key = { `${ line . timestamp } -${ index } ` }
430+ className = { colorClass }
431+ >
432+ { line . timestamp && (
433+ < span className = "text-gray-500" > [{ line . timestamp } ] </ span >
434+ ) }
435+ < span className = "uppercase text-gray-400" >
436+ { line . level }
437+ </ span > { " " }
438+ { line . message }
439+ </ div >
440+ ) ;
441+ } )
442+ ) }
443+ </ div >
444+ </ div >
285445 </ main >
286446
287447 { pendingAction && (
0 commit comments