Skip to content

Commit c9eaa20

Browse files
authored
Add timeline steps (#1)
1 parent 6853545 commit c9eaa20

File tree

9 files changed

+474
-80
lines changed

9 files changed

+474
-80
lines changed

bun.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"@radix-ui/react-tooltip": "^1.2.8",
9191
"@tanstack/react-query": "^5.90.10",
9292
"@tanstack/react-table": "^8.21.3",
93+
"@tanstack/react-virtual": "^3.13.12",
9394
"@uiw/react-json-view": "2.0.0-alpha.37",
9495
"bun-plugin-tailwind": "^0.1.2",
9596
"class-variance-authority": "^0.7.1",
@@ -649,6 +650,8 @@
649650

650651
"@tanstack/react-table": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
651652

653+
"@tanstack/react-virtual": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA=="],
654+
652655
"@tanstack/router-core": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/history": "1.139.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.0", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-HCDi4fpnAFeDDogT0C61yd2nJn0FrIyFDhyHG3xJji8emdn8Ni4rfyrN4Av46xKkXTPUGdbsqih45+uuNtunew=="],
653656

654657
"@tanstack/router-devtools-core": ["@tanstack/[email protected]", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3", "vite": "^7.1.7" }, "peerDependencies": { "@tanstack/router-core": "^1.139.12", "csstype": "^3.0.10", "solid-js": ">=1.9.5" }, "optionalPeers": ["csstype"] }, "sha512-VARlT9alLnROnPsZtHrSZsqYksIdBBQ24yGzEper5K1+1e0fzpcKLnMYLK9cwr//uWA2xmQayznvBnwcTmnUlg=="],
@@ -675,6 +678,8 @@
675678

676679
"@tanstack/table-core": ["@tanstack/[email protected]", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
677680

681+
"@tanstack/virtual-core": ["@tanstack/[email protected]", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="],
682+
678683
"@tanstack/virtual-file-routes": ["@tanstack/[email protected]", "", {}, "sha512-9PImF1d1tovTUIpjFVa0W7Fwj/MHif7BaaczgJJfbv3sDt1Gh+oW9W9uCw9M3ndEJynnp5ZD/TTs0RGubH5ssg=="],
679684

680685
"@tediousjs/connection-string": ["@tediousjs/[email protected]", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="],

packages/duron-dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@radix-ui/react-tooltip": "^1.2.8",
5656
"@tanstack/react-query": "^5.90.10",
5757
"@tanstack/react-table": "^8.21.3",
58+
"@tanstack/react-virtual": "^3.13.12",
5859
"@uiw/react-json-view": "2.0.0-alpha.37",
5960
"bun-plugin-tailwind": "^0.1.2",
6061
"class-variance-authority": "^0.7.1",

packages/duron-dashboard/src/components/badge-status.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Ban, CheckCircle2, Clock, Play, XCircle } from 'lucide-react'
22

33
import { Badge } from '@/components/ui/badge'
4+
import { cn } from '../lib/utils'
45

56
const icons = {
67
created: Clock,
@@ -18,9 +19,15 @@ const colors = {
1819
cancelled: 'bg-yellow-100 text-yellow-800 border-yellow-800',
1920
}
2021

21-
export function BadgeStatus({ status }: { status: string }) {
22+
export function BadgeStatus({ status, justIcon = false }: { status: string; justIcon?: boolean }) {
2223
const Icon = icons[status as keyof typeof icons]
2324
const color = colors[status as keyof typeof colors]
25+
if (justIcon) {
26+
return (
27+
<Badge className={cn(color, 'size-4 p-0 border-none')}>{Icon && <Icon height={'100%'} width={'100%'} />}</Badge>
28+
)
29+
}
30+
2431
return (
2532
<Badge variant="outline" className={color}>
2633
{Icon && <Icon className="mr-1 h-3 w-3" />}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use client'
2+
3+
import { Menu } from 'lucide-react'
4+
import { useState } from 'react'
5+
6+
import { Timeline } from '@/components/timeline'
7+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
8+
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
9+
import { Separator } from '@/components/ui/separator'
10+
import { useStepsPolling } from '@/hooks/use-steps-polling'
11+
import { useJob, useJobSteps } from '@/lib/api'
12+
import { StepDetailsContent } from '@/views/step-details-content'
13+
14+
interface TimelineModalProps {
15+
jobId: string | null
16+
open: boolean
17+
onOpenChange: (open: boolean) => void
18+
}
19+
20+
export function TimelineModal({ jobId, open, onOpenChange }: TimelineModalProps) {
21+
const [selectedStepId, setSelectedStepId] = useState<string | null>(null)
22+
const { data: job, isLoading: jobLoading } = useJob(jobId)
23+
const { data: stepsData, isLoading: stepsLoading } = useJobSteps(jobId, {
24+
page: 1,
25+
pageSize: 1000, // Get all steps for timeline
26+
})
27+
28+
// Enable polling for step updates
29+
useStepsPolling(jobId, open)
30+
31+
const steps = stepsData?.steps ?? []
32+
const isLoading = jobLoading || stepsLoading
33+
34+
// Reset selected step when modal closes
35+
const handleOpenChange = (newOpen: boolean) => {
36+
if (!newOpen) {
37+
setSelectedStepId(null)
38+
}
39+
onOpenChange(newOpen)
40+
}
41+
42+
// Toggle step selection - if clicking the same step, deselect it
43+
const handleStepSelect = (stepId: string) => {
44+
setSelectedStepId((current) => (current === stepId ? null : stepId))
45+
}
46+
47+
return (
48+
<Dialog open={open} onOpenChange={handleOpenChange}>
49+
<DialogContent className="max-w-[98vw]! sm:max-w-[98vw]! w-[98vw]! max-h-[98vh]! h-[98vh]! flex flex-col p-0 gap-0">
50+
<DialogHeader className="px-6 py-4 border-b shrink-0">
51+
<div className="flex items-center gap-2">
52+
<Menu className="h-5 w-5" />
53+
<DialogTitle>Timeline</DialogTitle>
54+
</div>
55+
</DialogHeader>
56+
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
57+
{/* Timeline Section */}
58+
<ScrollArea className={`overflow-hidden p-6 min-h-0 ${selectedStepId ? 'max-h-[50%] border-b' : 'flex-1'}`}>
59+
{isLoading ? (
60+
<div className="flex items-center justify-center h-full text-muted-foreground">Loading timeline...</div>
61+
) : (
62+
<Timeline
63+
job={job ?? null}
64+
steps={steps}
65+
selectedStepId={selectedStepId}
66+
onStepSelect={handleStepSelect}
67+
/>
68+
)}
69+
<ScrollBar orientation="horizontal" />
70+
</ScrollArea>
71+
72+
{/* Step Details Section */}
73+
{selectedStepId && (
74+
<>
75+
<Separator />
76+
<ScrollArea className="flex-1 min-h-0">
77+
<div className="p-6">
78+
<StepDetailsContent stepId={selectedStepId} jobId={jobId} />
79+
</div>
80+
<ScrollBar orientation="horizontal" />
81+
</ScrollArea>
82+
</>
83+
)}
84+
</div>
85+
</DialogContent>
86+
</Dialog>
87+
)
88+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
'use client'
2+
3+
import { useVirtualizer } from '@tanstack/react-virtual'
4+
import { CircleDot, GitBranch } from 'lucide-react'
5+
import { useEffect, useMemo, useRef, useState } from 'react'
6+
7+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
8+
import type { Job, JobStep } from '@/lib/api'
9+
import { calculateDurationSeconds, formatDurationSeconds } from '@/lib/duration'
10+
11+
interface TimelineItem {
12+
id: string
13+
name: string
14+
type: 'job' | 'step'
15+
startedAt: Date | string | number | null
16+
finishedAt: Date | string | number | null | undefined
17+
status: string
18+
level: number
19+
}
20+
21+
interface TimelineProps {
22+
job: Job | null
23+
steps: Omit<JobStep, 'output'>[]
24+
selectedStepId?: string | null
25+
onStepSelect?: (stepId: string) => void
26+
}
27+
28+
const ROW_HEIGHT = 48
29+
30+
export function Timeline({ job, steps, selectedStepId, onStepSelect }: TimelineProps) {
31+
const parentRef = useRef<HTMLDivElement>(null)
32+
33+
// Build timeline items from job and steps
34+
const timelineItems = useMemo<TimelineItem[]>(() => {
35+
if (!job) {
36+
return []
37+
}
38+
39+
const items: TimelineItem[] = []
40+
41+
// Add job as root item
42+
items.push({
43+
id: job.id,
44+
name: job.actionName,
45+
type: 'job',
46+
startedAt: job.startedAt,
47+
finishedAt: job.finishedAt,
48+
status: job.status,
49+
level: 0,
50+
})
51+
52+
// Add steps as children
53+
const sortedSteps = [...steps].sort((a, b) => {
54+
const aStart = a.startedAt ? new Date(a.startedAt).getTime() : 0
55+
const bStart = b.startedAt ? new Date(b.startedAt).getTime() : 0
56+
return aStart - bStart
57+
})
58+
59+
sortedSteps.forEach((step) => {
60+
items.push({
61+
id: step.id,
62+
name: step.name,
63+
type: 'step',
64+
startedAt: step.startedAt,
65+
finishedAt: step.finishedAt,
66+
status: step.status,
67+
level: 1,
68+
})
69+
})
70+
71+
return items
72+
}, [job, steps])
73+
74+
// Calculate timeline bounds (earliest start, latest end)
75+
const timelineBounds = useMemo(() => {
76+
if (timelineItems.length === 0 || !job?.startedAt) {
77+
return { startTime: 0, endTime: 1, totalDuration: 1 }
78+
}
79+
80+
const jobStartTime = new Date(job.startedAt).getTime()
81+
let earliestStart = jobStartTime
82+
let latestEnd = jobStartTime
83+
84+
timelineItems.forEach((item) => {
85+
if (item.startedAt) {
86+
const startTime = new Date(item.startedAt).getTime()
87+
if (startTime < earliestStart) {
88+
earliestStart = startTime
89+
}
90+
91+
const endTime = item.finishedAt ? new Date(item.finishedAt).getTime() : Date.now()
92+
if (endTime > latestEnd) {
93+
latestEnd = endTime
94+
}
95+
}
96+
})
97+
98+
const totalDuration = (latestEnd - earliestStart) / 1000 // Convert to seconds
99+
return {
100+
startTime: earliestStart,
101+
endTime: latestEnd,
102+
totalDuration: totalDuration || 1,
103+
}
104+
}, [timelineItems, job])
105+
106+
const virtualizer = useVirtualizer({
107+
count: timelineItems.length,
108+
getScrollElement: () => parentRef.current,
109+
estimateSize: () => ROW_HEIGHT,
110+
overscan: 10,
111+
})
112+
113+
// Update durations for active items
114+
const [_, setNow] = useState(Date.now())
115+
useEffect(() => {
116+
const hasActiveItems = timelineItems.some((item) => item.startedAt && !item.finishedAt && item.status === 'active')
117+
if (!hasActiveItems) {
118+
return
119+
}
120+
121+
const interval = setInterval(() => {
122+
setNow(Date.now())
123+
}, 100)
124+
125+
return () => clearInterval(interval)
126+
}, [timelineItems])
127+
128+
if (timelineItems.length === 0) {
129+
return (
130+
<div className="flex items-center justify-center h-full text-muted-foreground">No timeline data available</div>
131+
)
132+
}
133+
134+
return (
135+
<div className="h-full flex flex-col">
136+
<div className="flex-1 overflow-auto" ref={parentRef}>
137+
<div
138+
style={{
139+
height: `${virtualizer.getTotalSize()}px`,
140+
width: '100%',
141+
position: 'relative',
142+
}}
143+
>
144+
{virtualizer.getVirtualItems().map((virtualItem) => {
145+
const item = timelineItems[virtualItem.index]!
146+
const duration = calculateDurationSeconds(item.startedAt, item.finishedAt)
147+
const isActive = item.startedAt && !item.finishedAt && item.status === 'active'
148+
149+
// Calculate relative position and width
150+
let leftPercentage = 0
151+
let widthPercentage = 0
152+
153+
if (item.startedAt && timelineBounds.totalDuration > 0) {
154+
const itemStartTime = new Date(item.startedAt).getTime()
155+
const relativeStart = (itemStartTime - timelineBounds.startTime) / 1000 // seconds from timeline start
156+
leftPercentage = (relativeStart / timelineBounds.totalDuration) * 100
157+
158+
// Width is based on duration relative to total timeline duration
159+
widthPercentage = (duration / timelineBounds.totalDuration) * 100
160+
161+
// Ensure bar doesn't go outside bounds
162+
if (leftPercentage < 0) {
163+
widthPercentage += leftPercentage
164+
leftPercentage = 0
165+
}
166+
if (leftPercentage + widthPercentage > 100) {
167+
widthPercentage = 100 - leftPercentage
168+
}
169+
}
170+
171+
const isSelected = item.type === 'step' && item.id === selectedStepId
172+
const isClickable = item.type === 'step' && onStepSelect
173+
174+
const content = (
175+
<div key={`content-${item.id}`} className="flex items-center w-full px-6 py-3 min-w-0 gap-4">
176+
{/* Left side: Tree structure with icons and labels - fixed width */}
177+
<div
178+
className="flex items-center gap-3 min-w-0 flex-[0_0_300px]"
179+
style={{ paddingLeft: `${item.level * 20}px` }}
180+
>
181+
{item.type === 'job' ? (
182+
<GitBranch className="h-4 w-4 text-teal-500 shrink-0" />
183+
) : (
184+
<CircleDot className="h-4 w-4 text-teal-500 shrink-0" />
185+
)}
186+
<Tooltip>
187+
<TooltipTrigger asChild={true}>
188+
<span className="text-sm font-medium text-foreground truncate block min-w-0">{item.name}</span>
189+
</TooltipTrigger>
190+
<TooltipContent>
191+
<p>{item.name}</p>
192+
</TooltipContent>
193+
</Tooltip>
194+
</div>
195+
196+
{/* Right side: Duration and progress bar - takes remaining space */}
197+
<div className="flex items-center gap-4 flex-1 min-w-0">
198+
<div className="text-sm text-muted-foreground min-w-[90px] text-right font-mono shrink-0">
199+
{formatDurationSeconds(duration)}
200+
</div>
201+
<div className="flex-1 h-3 bg-muted/50 rounded-sm overflow-hidden relative min-w-0">
202+
{widthPercentage > 0 && (
203+
<div
204+
className={`h-full absolute transition-all duration-100 ${
205+
isActive ? 'bg-teal-500' : duration > 0 ? 'bg-teal-500/80' : 'bg-muted'
206+
}`}
207+
style={{
208+
left: `${leftPercentage}%`,
209+
width: `${Math.max(widthPercentage, 0.5)}%`,
210+
}}
211+
/>
212+
)}
213+
</div>
214+
</div>
215+
</div>
216+
)
217+
218+
const containerStyle = {
219+
position: 'absolute' as const,
220+
top: 0,
221+
left: 0,
222+
width: '100%',
223+
height: `${virtualItem.size}px`,
224+
transform: `translateY(${virtualItem.start}px)`,
225+
}
226+
227+
const containerClassName = `flex items-center border-b border-border/50 transition-colors ${
228+
isSelected ? 'bg-muted' : 'hover:bg-muted/50'
229+
}`
230+
231+
return isClickable ? (
232+
<button
233+
key={item.id}
234+
type="button"
235+
style={containerStyle}
236+
onClick={() => onStepSelect(item.id)}
237+
className={`${containerClassName} cursor-pointer w-full text-left`}
238+
>
239+
{content}
240+
</button>
241+
) : (
242+
<div key={item.id} style={containerStyle} className={containerClassName}>
243+
{content}
244+
</div>
245+
)
246+
})}
247+
</div>
248+
</div>
249+
</div>
250+
)
251+
}

0 commit comments

Comments
 (0)