Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@uiw/react-json-view": "2.0.0-alpha.37",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
Expand Down Expand Up @@ -649,6 +650,8 @@

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

"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.12", "", { "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=="],

"@tanstack/router-core": ["@tanstack/router-core@1.139.12", "", { "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=="],

"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.139.12", "", { "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=="],
Expand All @@ -675,6 +678,8 @@

"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],

"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="],

"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.139.0", "", {}, "sha512-9PImF1d1tovTUIpjFVa0W7Fwj/MHif7BaaczgJJfbv3sDt1Gh+oW9W9uCw9M3ndEJynnp5ZD/TTs0RGubH5ssg=="],

"@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="],
Expand Down
1 change: 1 addition & 0 deletions packages/duron-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@uiw/react-json-view": "2.0.0-alpha.37",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
Expand Down
9 changes: 8 additions & 1 deletion packages/duron-dashboard/src/components/badge-status.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Ban, CheckCircle2, Clock, Play, XCircle } from 'lucide-react'

import { Badge } from '@/components/ui/badge'
import { cn } from '../lib/utils'

const icons = {
created: Clock,
Expand All @@ -18,9 +19,15 @@ const colors = {
cancelled: 'bg-yellow-100 text-yellow-800 border-yellow-800',
}

export function BadgeStatus({ status }: { status: string }) {
export function BadgeStatus({ status, justIcon = false }: { status: string; justIcon?: boolean }) {
const Icon = icons[status as keyof typeof icons]
const color = colors[status as keyof typeof colors]
if (justIcon) {
return (
<Badge className={cn(color, 'size-4 p-0 border-none')}>{Icon && <Icon height={'100%'} width={'100%'} />}</Badge>
)
}

return (
<Badge variant="outline" className={color}>
{Icon && <Icon className="mr-1 h-3 w-3" />}
Expand Down
88 changes: 88 additions & 0 deletions packages/duron-dashboard/src/components/timeline-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use client'

import { Menu } from 'lucide-react'
import { useState } from 'react'

import { Timeline } from '@/components/timeline'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { useStepsPolling } from '@/hooks/use-steps-polling'
import { useJob, useJobSteps } from '@/lib/api'
import { StepDetailsContent } from '@/views/step-details-content'

interface TimelineModalProps {
jobId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}

export function TimelineModal({ jobId, open, onOpenChange }: TimelineModalProps) {
const [selectedStepId, setSelectedStepId] = useState<string | null>(null)
const { data: job, isLoading: jobLoading } = useJob(jobId)
const { data: stepsData, isLoading: stepsLoading } = useJobSteps(jobId, {
page: 1,
pageSize: 1000, // Get all steps for timeline
})

// Enable polling for step updates
useStepsPolling(jobId, open)

const steps = stepsData?.steps ?? []
const isLoading = jobLoading || stepsLoading

// Reset selected step when modal closes
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setSelectedStepId(null)
}
onOpenChange(newOpen)
}

// Toggle step selection - if clicking the same step, deselect it
const handleStepSelect = (stepId: string) => {
setSelectedStepId((current) => (current === stepId ? null : stepId))
}

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[98vw]! sm:max-w-[98vw]! w-[98vw]! max-h-[98vh]! h-[98vh]! flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<div className="flex items-center gap-2">
<Menu className="h-5 w-5" />
<DialogTitle>Timeline</DialogTitle>
</div>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
{/* Timeline Section */}
<ScrollArea className={`overflow-hidden p-6 min-h-0 ${selectedStepId ? 'max-h-[50%] border-b' : 'flex-1'}`}>
{isLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground">Loading timeline...</div>
) : (
<Timeline
job={job ?? null}
steps={steps}
selectedStepId={selectedStepId}
onStepSelect={handleStepSelect}
/>
)}
<ScrollBar orientation="horizontal" />
</ScrollArea>

{/* Step Details Section */}
{selectedStepId && (
<>
<Separator />
<ScrollArea className="flex-1 min-h-0">
<div className="p-6">
<StepDetailsContent stepId={selectedStepId} jobId={jobId} />
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</>
)}
</div>
</DialogContent>
</Dialog>
)
}
251 changes: 251 additions & 0 deletions packages/duron-dashboard/src/components/timeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
'use client'

import { useVirtualizer } from '@tanstack/react-virtual'
import { CircleDot, GitBranch } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'

import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { Job, JobStep } from '@/lib/api'
import { calculateDurationSeconds, formatDurationSeconds } from '@/lib/duration'

interface TimelineItem {
id: string
name: string
type: 'job' | 'step'
startedAt: Date | string | number | null
finishedAt: Date | string | number | null | undefined
status: string
level: number
}

interface TimelineProps {
job: Job | null
steps: Omit<JobStep, 'output'>[]
selectedStepId?: string | null
onStepSelect?: (stepId: string) => void
}

const ROW_HEIGHT = 48

export function Timeline({ job, steps, selectedStepId, onStepSelect }: TimelineProps) {
const parentRef = useRef<HTMLDivElement>(null)

// Build timeline items from job and steps
const timelineItems = useMemo<TimelineItem[]>(() => {
if (!job) {
return []
}

const items: TimelineItem[] = []

// Add job as root item
items.push({
id: job.id,
name: job.actionName,
type: 'job',
startedAt: job.startedAt,
finishedAt: job.finishedAt,
status: job.status,
level: 0,
})

// Add steps as children
const sortedSteps = [...steps].sort((a, b) => {
const aStart = a.startedAt ? new Date(a.startedAt).getTime() : 0
const bStart = b.startedAt ? new Date(b.startedAt).getTime() : 0
return aStart - bStart
})

sortedSteps.forEach((step) => {
items.push({
id: step.id,
name: step.name,
type: 'step',
startedAt: step.startedAt,
finishedAt: step.finishedAt,
status: step.status,
level: 1,
})
})

return items
}, [job, steps])

// Calculate timeline bounds (earliest start, latest end)
const timelineBounds = useMemo(() => {
if (timelineItems.length === 0 || !job?.startedAt) {
return { startTime: 0, endTime: 1, totalDuration: 1 }
}

const jobStartTime = new Date(job.startedAt).getTime()
let earliestStart = jobStartTime
let latestEnd = jobStartTime

timelineItems.forEach((item) => {
if (item.startedAt) {
const startTime = new Date(item.startedAt).getTime()
if (startTime < earliestStart) {
earliestStart = startTime
}

const endTime = item.finishedAt ? new Date(item.finishedAt).getTime() : Date.now()
if (endTime > latestEnd) {
latestEnd = endTime
}
}
})

const totalDuration = (latestEnd - earliestStart) / 1000 // Convert to seconds
return {
startTime: earliestStart,
endTime: latestEnd,
totalDuration: totalDuration || 1,
}
}, [timelineItems, job])

const virtualizer = useVirtualizer({
count: timelineItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
})

// Update durations for active items
const [_, setNow] = useState(Date.now())
useEffect(() => {
const hasActiveItems = timelineItems.some((item) => item.startedAt && !item.finishedAt && item.status === 'active')
if (!hasActiveItems) {
return
}

const interval = setInterval(() => {
setNow(Date.now())
}, 100)

return () => clearInterval(interval)
}, [timelineItems])

if (timelineItems.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">No timeline data available</div>
)
}

return (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto" ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = timelineItems[virtualItem.index]!
const duration = calculateDurationSeconds(item.startedAt, item.finishedAt)
const isActive = item.startedAt && !item.finishedAt && item.status === 'active'

// Calculate relative position and width
let leftPercentage = 0
let widthPercentage = 0

if (item.startedAt && timelineBounds.totalDuration > 0) {
const itemStartTime = new Date(item.startedAt).getTime()
const relativeStart = (itemStartTime - timelineBounds.startTime) / 1000 // seconds from timeline start
leftPercentage = (relativeStart / timelineBounds.totalDuration) * 100

// Width is based on duration relative to total timeline duration
widthPercentage = (duration / timelineBounds.totalDuration) * 100

// Ensure bar doesn't go outside bounds
if (leftPercentage < 0) {
widthPercentage += leftPercentage
leftPercentage = 0
}
if (leftPercentage + widthPercentage > 100) {
widthPercentage = 100 - leftPercentage
}
}

const isSelected = item.type === 'step' && item.id === selectedStepId
const isClickable = item.type === 'step' && onStepSelect

const content = (
<div key={`content-${item.id}`} className="flex items-center w-full px-6 py-3 min-w-0 gap-4">
{/* Left side: Tree structure with icons and labels - fixed width */}
<div
className="flex items-center gap-3 min-w-0 flex-[0_0_300px]"
style={{ paddingLeft: `${item.level * 20}px` }}
>
{item.type === 'job' ? (
<GitBranch className="h-4 w-4 text-teal-500 shrink-0" />
) : (
<CircleDot className="h-4 w-4 text-teal-500 shrink-0" />
)}
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="text-sm font-medium text-foreground truncate block min-w-0">{item.name}</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.name}</p>
</TooltipContent>
</Tooltip>
</div>

{/* Right side: Duration and progress bar - takes remaining space */}
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="text-sm text-muted-foreground min-w-[90px] text-right font-mono shrink-0">
{formatDurationSeconds(duration)}
</div>
<div className="flex-1 h-3 bg-muted/50 rounded-sm overflow-hidden relative min-w-0">
{widthPercentage > 0 && (
<div
className={`h-full absolute transition-all duration-100 ${
isActive ? 'bg-teal-500' : duration > 0 ? 'bg-teal-500/80' : 'bg-muted'
}`}
style={{
left: `${leftPercentage}%`,
width: `${Math.max(widthPercentage, 0.5)}%`,
}}
/>
)}
</div>
</div>
</div>
)

const containerStyle = {
position: 'absolute' as const,
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}

const containerClassName = `flex items-center border-b border-border/50 transition-colors ${
isSelected ? 'bg-muted' : 'hover:bg-muted/50'
}`

return isClickable ? (
<button
key={item.id}
type="button"
style={containerStyle}
onClick={() => onStepSelect(item.id)}
className={`${containerClassName} cursor-pointer w-full text-left`}
>
{content}
</button>
) : (
<div key={item.id} style={containerStyle} className={containerClassName}>
{content}
</div>
)
})}
</div>
</div>
</div>
)
}
Loading