11import { useState } from 'react'
2- import { CheckIcon , ChevronDownIcon , ChevronRightIcon , XIcon } from '@primer/octicons-react'
2+ import { CheckIcon , ChevronDownIcon , FileDirectoryIcon , XIcon } from '@primer/octicons-react'
33import { format } from 'date-fns'
44import { useAtom } from 'jotai'
55
@@ -23,100 +23,256 @@ const formatDateTime = (isoDate: string): string => {
2323 }
2424}
2525
26- export const mocks = [
27- {
28- arguments : '--env=prod --force' ,
29- build_id : '0198b32b-6ede-7be2-99dc-aee8c7ef358d' ,
30- end_at : '2025-08-13T16:20:00Z' ,
31- exit_code : 0 ,
32- cl : 'CL-125' ,
33- output_file : 'output_build_20250813001.zip' ,
34- repo_name : 'frontend-webapp' ,
35- start_at : '2025-08-13T16:15:00Z' ,
36- target : 'production'
37- } ,
38- {
39- arguments : '--env=dev --skip-tests' ,
40- build_id : 'BUILD_20250813002' ,
41- end_at : '2025-08-13T17:05:00Z' ,
42- exit_code : 1 ,
43- cl : 'CL-126' ,
44- output_file : 'output_build_20250813002.zip' ,
45- repo_name : 'backend-service' ,
46- start_at : '2025-08-13T16:50:00Z' ,
47- target : 'development'
48- } ,
49- {
50- arguments : '--env=test' ,
51- build_id : 'BUILD_20250813003' ,
52- end_at : '2025-08-13T18:30:00Z' ,
53- exit_code : 0 ,
54- cl : 'CL-127' ,
55- output_file : 'output_build_20250813003.zip' ,
56- repo_name : 'data-processor' ,
57- start_at : '2025-08-13T18:10:00Z' ,
58- target : 'testing'
26+ type LogStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'
27+
28+ /**
29+ * Tree Root Component - Top level node showing the path
30+ */
31+ export const TreeRoot = ( {
32+ path,
33+ tasks,
34+ logStatus
35+ } : {
36+ path ?: string
37+ tasks : TaskInfoDTO [ ]
38+ logStatus : Record < string , LogStatus >
39+ } ) => {
40+ const [ isExpanded , setIsExpanded ] = useState ( true )
41+
42+ return (
43+ < div className = 'select-none' >
44+ < div
45+ onClick = { ( ) => setIsExpanded ( ! isExpanded ) }
46+ className = 'group flex w-full cursor-pointer items-center gap-2.5 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-transparent px-3 py-3 transition-all hover:from-blue-100 hover:to-blue-50 dark:border-gray-700 dark:from-blue-900/20 dark:hover:from-blue-900/30 dark:hover:to-blue-900/10'
47+ >
48+ < div className = 'flex items-center gap-2' >
49+ < div
50+ className = 'transition-transform duration-200'
51+ style = { { transform : isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } }
52+ >
53+ < ChevronDownIcon size = { 16 } className = 'text-gray-600 dark:text-gray-400' />
54+ </ div >
55+ < FileDirectoryIcon size = { 16 } className = 'text-blue-600 dark:text-blue-400' />
56+ </ div >
57+ < span className = 'text-sm font-semibold text-gray-800 dark:text-gray-200' > { path || 'Project Root' } </ span >
58+ < span className = 'ml-auto rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' >
59+ { tasks . length } { tasks . length === 1 ? 'build' : 'builds' }
60+ </ span >
61+ </ div >
62+ { isExpanded && (
63+ < div className = 'bg-gray-50/50 dark:bg-gray-900/20' >
64+ { tasks . map ( ( t , index ) => (
65+ < Task key = { t . task_id } list = { t } logStatus = { logStatus } isLast = { index === tasks . length - 1 } />
66+ ) ) }
67+ </ div >
68+ ) }
69+ </ div >
70+ )
71+ }
72+
73+ /**
74+ * Task Component - Second level showing task name and builds
75+ */
76+ export const Task = ( {
77+ list,
78+ logStatus,
79+ isLast
80+ } : {
81+ list : TaskInfoDTO
82+ logStatus : Record < string , LogStatus >
83+ isLast ?: boolean
84+ } ) => {
85+ const [ isExpanded , setIsExpanded ] = useState ( false )
86+
87+ // Extract filename from output_file path (from first target's first build)
88+ const getFileName = ( ) => {
89+ if ( ! list . targets || list . targets . length === 0 ) return list . task_name || 'Unnamed Task'
90+
91+ const firstTarget = list . targets [ 0 ] as any
92+
93+ if ( ! firstTarget . builds || firstTarget . builds . length === 0 ) return list . task_name || 'Unnamed Task'
94+
95+ const firstBuild = firstTarget . builds [ 0 ]
96+
97+ if ( ! firstBuild . output_file ) return list . task_name || 'Unnamed Task'
98+
99+ const parts = firstBuild . output_file . split ( '/' )
100+
101+ return parts [ parts . length - 1 ] || 'Unnamed Task'
59102 }
60- ]
61103
62- type LogStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'
104+ // Get overall task status from targets with styled badge
105+ const getTaskStatus = ( ) => {
106+ if ( ! list . targets || list . targets . length === 0 ) return null
107+
108+ const states = list . targets . map ( ( t ) => t . state )
109+
110+ // If any target failed, show failed
111+ if ( states . some ( ( s ) => s === 'Failed' ) ) {
112+ return {
113+ status : 'Failed' ,
114+ icon : < XIcon size = { 14 } className = 'text-red-600 dark:text-red-400' /> ,
115+ badgeClass : 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
116+ }
117+ }
118+
119+ // If any target interrupted, show interrupted
120+ if ( states . some ( ( s ) => s === 'Interrupted' ) ) {
121+ return {
122+ status : 'Interrupted' ,
123+ icon : < XIcon size = { 14 } className = 'text-orange-600 dark:text-orange-400' /> ,
124+ badgeClass : 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300'
125+ }
126+ }
63127
64- export const Task = ( { list, logStatus } : { list : TaskInfoDTO ; logStatus : Record < string , LogStatus > } ) => {
65- const [ extend , setExtend ] = useState ( false )
128+ // If any target is building, show building
129+ if ( states . some ( ( s ) => s === 'Building' ) ) {
130+ return {
131+ status : 'Building' ,
132+ icon : < LoadingSpinner /> ,
133+ badgeClass : 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
134+ }
135+ }
66136
67- // list = mocks
137+ // If any target is pending, show pending
138+ if ( states . some ( ( s ) => s === 'Pending' ) ) {
139+ return {
140+ status : 'Pending' ,
141+ icon : < LoadingSpinner /> ,
142+ badgeClass : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300'
143+ }
144+ }
145+
146+ // If all completed, show completed
147+ if ( states . every ( ( s ) => s === 'Completed' ) ) {
148+ return {
149+ status : 'Completed' ,
150+ icon : < CheckIcon size = { 14 } className = 'text-green-700 dark:text-green-400' /> ,
151+ badgeClass : 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
152+ }
153+ }
154+
155+ return null
156+ }
157+
158+ const taskStatus = getTaskStatus ( )
159+ const fileName = getFileName ( )
68160
69161 return (
70- < >
162+ < div className = { `relative ${ ! isLast ? 'border-b border-gray-200 dark:border-gray-700' : '' } ` } >
163+ { /* Vertical line connector */ }
164+ < div className = 'absolute left-6 top-0 h-full w-px bg-gray-300 dark:bg-gray-600' />
165+
71166 < div
72- onClick = { ( ) => setExtend ( ! extend ) }
73- className = 'border-primary bg-primary flex w-full cursor-pointer items-center gap-4 border border-t-0 pl-4 '
167+ onClick = { ( ) => setIsExpanded ( ! isExpanded ) }
168+ className = 'group relative flex w-full cursor-pointer items-center gap-3 bg-white px-3 py-2.5 transition-all hover:bg-gray-100 dark:bg-gray-800/50 dark:hover:bg-gray-700/50 '
74169 >
75- { extend ? < ChevronRightIcon size = { 16 } /> : < ChevronDownIcon size = { 16 } /> }
76- < div className = 'flex flex-col justify-center' >
77- < span className = 'text-primary font-weight fz-[14px]' > { list . task_name } </ span >
78- < span className = 'text-tertiary fz-[12px] font-light' > { formatDateTime ( list . created_at ) } </ span >
170+ { /* Horizontal line connector */ }
171+ < div className = 'absolute left-6 top-1/2 h-px w-3 bg-gray-300 dark:bg-gray-600' />
172+
173+ < div className = 'relative z-10 ml-6 flex items-center gap-2' >
174+ < div
175+ className = 'transition-transform duration-200'
176+ style = { { transform : isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } }
177+ >
178+ < ChevronDownIcon size = { 14 } className = 'text-gray-500 dark:text-gray-400' />
179+ </ div >
79180 </ div >
80- { /* {extend && list} */ }
181+
182+ < div className = 'flex flex-1 flex-col gap-0.5' >
183+ < span className = 'text-sm font-medium text-gray-800 dark:text-gray-200' > { fileName } </ span >
184+ < span className = 'text-xs text-gray-500 dark:text-gray-400' > { formatDateTime ( list . created_at ) } </ span >
185+ </ div >
186+
187+ { taskStatus && (
188+ < div className = 'flex items-center gap-2' >
189+ { taskStatus . icon }
190+ < span className = { `rounded-md px-2 py-0.5 text-xs font-medium ${ taskStatus . badgeClass } ` } >
191+ { taskStatus . status }
192+ </ span >
193+ </ div >
194+ ) }
81195 </ div >
82- { ! extend && list && (
83- < div className = 'fz-[14px] border-b pl-4 font-medium' >
84- { list . build_list . map ( ( i ) => (
85- < TaskItem key = { i . id } build = { i } logStatus = { logStatus [ i . id ] } />
196+
197+ { isExpanded && list && (
198+ < div className = 'bg-gray-50 dark:bg-gray-900/30' >
199+ { list . build_list . map ( ( i , index ) => (
200+ < TaskItem key = { i . id } build = { i } logStatus = { logStatus [ i . id ] } isLast = { index === list . build_list . length - 1 } />
86201 ) ) }
87202 </ div >
88203 ) }
89- </ >
204+ </ div >
90205 )
91206}
92207
93- export const TaskItem = ( { build, logStatus } : { build : BuildDTO ; logStatus ?: LogStatus } ) => {
94- // const [statusMap] = useAtom(statusMapAtom)
208+ /**
209+ * TaskItem Component - Third level showing individual builds
210+ */
211+ export const TaskItem = ( {
212+ build,
213+ logStatus,
214+ isLast
215+ } : {
216+ build : BuildDTO
217+ logStatus ?: LogStatus
218+ isLast ?: boolean
219+ } ) => {
220+ const [ buildId , setBuildId ] = useAtom ( buildIdAtom )
95221
96- const [ _ , setBuildId ] = useAtom ( buildIdAtom )
97222 const handleClick = ( build_id : string ) => {
98- // 此处建立连接
99- // setLoading(true)
100223 setBuildId ( build_id )
101- // if (eventSourcesRef.current[build_id]) return
102- // setEventSource(build_id)
103224 }
104225
226+ const isSelected = buildId === build . id
105227 const isHighlighted = logStatus === 'success'
106- const textColor = isHighlighted ? 'text-[#0969da]' : 'text-[#59636e]'
228+
229+ let bgClass = 'bg-white dark:bg-gray-800/30'
230+ let textColor = 'text-gray-600 dark:text-gray-400'
231+ let borderColor = 'border-gray-300 dark:border-gray-600'
232+
233+ if ( isSelected ) {
234+ bgClass = 'bg-blue-50 dark:bg-blue-900/20'
235+ textColor = 'text-blue-700 dark:text-blue-300'
236+ borderColor = 'border-blue-400 dark:border-blue-500'
237+ } else if ( isHighlighted ) {
238+ textColor = 'text-blue-600 dark:text-blue-400'
239+ }
107240
108241 return (
109- < >
242+ < div className = 'relative' >
243+ { /* Vertical line connector */ }
244+ < div className = { `absolute left-6 top-0 ${ isLast ? 'h-1/2' : 'h-full' } w-px bg-gray-300 dark:bg-gray-600` } />
245+
110246 < div
111247 onClick = { ( ) => handleClick ( build . id ) }
112- className = '!fz-[14px] flex !h-[37px] items-center gap-2'
113- key = { build . id }
248+ className = { `group relative flex h-10 cursor-pointer items-center gap-3 px-3 transition-all hover:bg-gray-100 dark:hover:bg-gray-700/30 ${ bgClass } ` }
114249 >
115- { /* {identifyStatus(statusMap.get(build.id)?.status || Status.NotFound)} */ }
116- { identifyStatus ( build . status || Status . NotFound ) }
117- < span className = { `hover:text-primary cursor-pointer ${ textColor } ` } > { build . id } </ span >
250+ { /* Horizontal line connector */ }
251+ < div className = { `absolute left-6 top-1/2 h-px w-6 ${ borderColor } ` } />
252+
253+ { /* Status dot */ }
254+ < div
255+ className = { `relative z-10 ml-12 flex h-2 w-2 items-center justify-center rounded-full ${ borderColor } border-2 bg-white dark:bg-gray-800` }
256+ >
257+ { isSelected && < div className = 'h-1 w-1 rounded-full bg-blue-500' /> }
258+ </ div >
259+
260+ < div className = 'flex items-center gap-2' >
261+ { identifyStatus ( build . status || Status . NotFound ) }
262+ < span
263+ className = { `font-mono text-sm transition-colors ${ textColor } group-hover:text-blue-600 dark:group-hover:text-blue-400` }
264+ >
265+ { build . id }
266+ </ span >
267+ </ div >
268+
269+ { isSelected && (
270+ < div className = 'ml-auto' >
271+ < div className = 'h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500' />
272+ </ div >
273+ ) }
118274 </ div >
119- </ >
275+ </ div >
120276 )
121277}
122278
0 commit comments