diff --git a/bun.lock b/bun.lock index f89210d..1ed9a91 100644 --- a/bun.lock +++ b/bun.lock @@ -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", @@ -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=="], @@ -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=="], diff --git a/packages/duron-dashboard/package.json b/packages/duron-dashboard/package.json index b1d3b09..6acddd0 100644 --- a/packages/duron-dashboard/package.json +++ b/packages/duron-dashboard/package.json @@ -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", diff --git a/packages/duron-dashboard/src/components/badge-status.tsx b/packages/duron-dashboard/src/components/badge-status.tsx index 7ce65e9..fdc23cc 100644 --- a/packages/duron-dashboard/src/components/badge-status.tsx +++ b/packages/duron-dashboard/src/components/badge-status.tsx @@ -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, @@ -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 ( + {Icon && } + ) + } + return ( {Icon && } diff --git a/packages/duron-dashboard/src/components/timeline-modal.tsx b/packages/duron-dashboard/src/components/timeline-modal.tsx new file mode 100644 index 0000000..f9737d6 --- /dev/null +++ b/packages/duron-dashboard/src/components/timeline-modal.tsx @@ -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(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 ( + + + +
+ + Timeline +
+
+
+ {/* Timeline Section */} + + {isLoading ? ( +
Loading timeline...
+ ) : ( + + )} + +
+ + {/* Step Details Section */} + {selectedStepId && ( + <> + + +
+ +
+ +
+ + )} +
+
+
+ ) +} diff --git a/packages/duron-dashboard/src/components/timeline.tsx b/packages/duron-dashboard/src/components/timeline.tsx new file mode 100644 index 0000000..bed23f9 --- /dev/null +++ b/packages/duron-dashboard/src/components/timeline.tsx @@ -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[] + selectedStepId?: string | null + onStepSelect?: (stepId: string) => void +} + +const ROW_HEIGHT = 48 + +export function Timeline({ job, steps, selectedStepId, onStepSelect }: TimelineProps) { + const parentRef = useRef(null) + + // Build timeline items from job and steps + const timelineItems = useMemo(() => { + 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 ( +
No timeline data available
+ ) + } + + return ( +
+
+
+ {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 = ( +
+ {/* Left side: Tree structure with icons and labels - fixed width */} +
+ {item.type === 'job' ? ( + + ) : ( + + )} + + + {item.name} + + +

{item.name}

+
+
+
+ + {/* Right side: Duration and progress bar - takes remaining space */} +
+
+ {formatDurationSeconds(duration)} +
+
+ {widthPercentage > 0 && ( +
0 ? 'bg-teal-500/80' : 'bg-muted' + }`} + style={{ + left: `${leftPercentage}%`, + width: `${Math.max(widthPercentage, 0.5)}%`, + }} + /> + )} +
+
+
+ ) + + 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 ? ( + + ) : ( +
+ {content} +
+ ) + })} +
+
+
+ ) +} diff --git a/packages/duron-dashboard/src/components/ui/tooltip.tsx b/packages/duron-dashboard/src/components/ui/tooltip.tsx index 375b767..919aea9 100644 --- a/packages/duron-dashboard/src/components/ui/tooltip.tsx +++ b/packages/duron-dashboard/src/components/ui/tooltip.tsx @@ -37,7 +37,7 @@ function TooltipContent({ {...props} > {children} - + ) diff --git a/packages/duron-dashboard/src/lib/duration.ts b/packages/duron-dashboard/src/lib/duration.ts new file mode 100644 index 0000000..139b895 --- /dev/null +++ b/packages/duron-dashboard/src/lib/duration.ts @@ -0,0 +1,24 @@ +/** + * Calculate duration in seconds from start and end timestamps + */ +export function calculateDurationSeconds( + startedAt: Date | string | number | null | undefined, + finishedAt: Date | string | number | null | undefined, +): number { + if (!startedAt) { + return 0 + } + const startTime = new Date(startedAt).getTime() + const endTime = finishedAt ? new Date(finishedAt).getTime() : Date.now() + return (endTime - startTime) / 1000 +} + +/** + * Format duration in seconds to a readable string (e.g., "5.442 s") + */ +export function formatDurationSeconds(seconds: number): string { + if (seconds === 0) { + return '0.000 s' + } + return `${seconds.toFixed(3)} s` +} diff --git a/packages/duron-dashboard/src/views/step-details-content.tsx b/packages/duron-dashboard/src/views/step-details-content.tsx index ed5af9f..978bd3f 100644 --- a/packages/duron-dashboard/src/views/step-details-content.tsx +++ b/packages/duron-dashboard/src/views/step-details-content.tsx @@ -99,7 +99,7 @@ export function StepDetailsContent({ stepId, jobId }: StepDetailsContentProps) {
-
+
Step: {step.name}
diff --git a/packages/duron-dashboard/src/views/step-list.tsx b/packages/duron-dashboard/src/views/step-list.tsx index 697e66c..e11d061 100644 --- a/packages/duron-dashboard/src/views/step-list.tsx +++ b/packages/duron-dashboard/src/views/step-list.tsx @@ -1,11 +1,13 @@ 'use client' -import { Search } from 'lucide-react' +import { Clock, Search } from 'lucide-react' import { useCallback, useState } from 'react' +import { TimelineModal } from '@/components/timeline-modal' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' +import { ScrollArea } from '@/components/ui/scroll-area' import { useDebouncedCallback } from '@/hooks/use-debounced-callback' import { useStepsPolling } from '@/hooks/use-steps-polling' import { useJobSteps } from '@/lib/api' @@ -22,6 +24,7 @@ export function StepList({ jobId, selectedStepId, onStepSelect }: StepListProps) const [inputValue, setInputValue] = useState('') const [searchTerm, setSearchTerm] = useState('') const [page, setPage] = useState(1) + const [timelineOpen, setTimelineOpen] = useState(false) const pageSize = 20 // Debounce the search term update with 1000ms delay @@ -56,86 +59,101 @@ export function StepList({ jobId, selectedStepId, onStepSelect }: StepListProps) const steps = stepsData?.steps ?? [] return ( -
-
-
- - handleSearchChange(e.target.value)} - className="pl-8" - /> -
-
- - -
- {stepsLoading ? ( -
Loading steps...
- ) : steps.length === 0 ? ( -
- {inputValue ? 'No steps found matching your search' : 'No steps found'} + <> +
+
+
+
+ + handleSearchChange(e.target.value)} + className="pl-8" + />
- ) : ( - setTimelineOpen(true)} + className="shrink-0" + title="View Timeline" > - {steps.map((step, index) => { - const stepNumber = (page - 1) * pageSize + index + 1 - return ( - - -
-
- #{stepNumber} - {step.name} -
- -
-
- - - -
- ) - })} -
- )} + + Timeline + +
+
- {stepsData && stepsData.total > pageSize && ( -
-
- Showing {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, stepsData.total)} of {stepsData.total}{' '} - steps -
-
- - -
+ {steps.map((step, index) => { + const stepNumber = (page - 1) * pageSize + index + 1 + return ( + + +
+
+ #{stepNumber} + {step.name} +
+ +
+
+ + + +
+ ) + })} + + )}
- )} +
- - -
+ {stepsData && stepsData.total > pageSize && ( +
+
+ Showing {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, stepsData.total)} of {stepsData.total}{' '} + steps +
+
+ + +
+
+ )} +
+ + ) }