Skip to content

Commit 494bd66

Browse files
authored
feat(log): implement log viewing with tree component (#1898)
1 parent f0e0aad commit 494bd66

3 files changed

Lines changed: 229 additions & 75 deletions

File tree

moon/apps/web/components/ClView/components/Checks/cpns/Task.tsx

Lines changed: 223 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'react'
2-
import { CheckIcon, ChevronDownIcon, ChevronRightIcon, XIcon } from '@primer/octicons-react'
2+
import { CheckIcon, ChevronDownIcon, FileDirectoryIcon, XIcon } from '@primer/octicons-react'
33
import { format } from 'date-fns'
44
import { 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

moon/apps/web/components/ClView/components/Checks/index.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import { fetchHTTPLog } from '@/hooks/SSE/useGetHTTPLog'
1010

1111
import { useTaskSSE } from '../../hook/useSSM'
1212
import { statusMapAtom } from './cpns/store'
13-
import { Task } from './cpns/Task'
13+
import { TreeRoot } from './cpns/Task'
1414

1515
type LogStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'
1616

1717
const MIN_LEFT_WIDTH = 200
1818
const MAX_LEFT_WIDTH_PERCENT = 0.7
19-
const DEFAULT_LEFT_WIDTH_PERCENT = 0.4
19+
const DEFAULT_LEFT_WIDTH_PERCENT = 0.3
2020

21-
const Checks = ({ cl }: { cl: number }) => {
21+
const Checks = ({ cl, path }: { cl: number; path?: string }) => {
2222
const [buildid, setBuildId] = useAtom(buildIdAtom)
2323
const { logsMap, setEventSource, eventSourcesRef, setLogsMap } = useTaskSSE()
2424
const [statusMap, _setStatusMap] = useAtom(statusMapAtom)
@@ -362,11 +362,9 @@ const Checks = ({ cl }: { cl: number }) => {
362362
<div
363363
ref={leftPanelRef}
364364
className='border-primary h-full overflow-y-auto border-r'
365-
style={{ width: leftWidth ?? '40%', flexShrink: 0 }}
365+
style={{ width: leftWidth ?? '30%', flexShrink: 0 }}
366366
>
367-
{validTasks.map((t) => (
368-
<Task key={t.task_id} list={t} logStatus={logStatus} />
369-
))}
367+
<TreeRoot path={path} tasks={validTasks} logStatus={logStatus} />
370368
</div>
371369
{/* Resizer handle */}
372370
<div

moon/apps/web/pages/[org]/cl/[link]/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ const CLDetailPage: PageWithLayout<any> = () => {
299299
)}
300300
<div>
301301
<TabLayout>
302-
{tab === 'check' && clDetail?.id && <Checks cl={clDetail.id} />}
302+
{tab === 'check' && clDetail?.id && <Checks cl={clDetail.id} path={(clDetail as any)?.path} />}
303303
{tab === 'conversation' && (
304304
<ConversationTab
305305
detailIsLoading={detailIsLoading}

0 commit comments

Comments
 (0)