From e8c3f4dcf73a2df1b76c44523cd1d776b471d7b8 Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sun, 28 Dec 2025 03:25:29 +0900 Subject: [PATCH] improve timeline tab content --- .../features/dag-runs/hooks/useHasOutputs.ts | 47 --- ui/src/features/dags/components/DAGStatus.tsx | 18 +- .../visualization/TimelineChart.tsx | 301 +++++++++++++++--- 3 files changed, 268 insertions(+), 98 deletions(-) delete mode 100644 ui/src/features/dag-runs/hooks/useHasOutputs.ts diff --git a/ui/src/features/dag-runs/hooks/useHasOutputs.ts b/ui/src/features/dag-runs/hooks/useHasOutputs.ts deleted file mode 100644 index d7c3356c..00000000 --- a/ui/src/features/dag-runs/hooks/useHasOutputs.ts +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { Status } from '../../../api/v2/schema'; -import { useQuery } from '../../../hooks/api'; -import { AppBarContext } from '../../../contexts/AppBarContext'; - -export function useHasOutputs( - dagName: string, - dagRunId: string, - status: Status, - isSubDAGRun: boolean = false, - _parentName?: string, - _parentDagRunId?: string -): boolean { - const appBarContext = React.useContext(AppBarContext); - - const isCompleted = - status === Status.Success || - status === Status.Failed || - status === Status.Aborted; - - // Sub-DAG runs don't have outputs endpoint yet, so skip fetching - const shouldFetch = !isSubDAGRun && isCompleted && !!dagName && !!dagRunId; - - const { data, error } = useQuery( - '/dag-runs/{name}/{dagRunId}/outputs', - { - params: { - query: { remoteNode: appBarContext.selectedRemoteNode || 'local' }, - path: { name: dagName || '', dagRunId: dagRunId || '' }, - }, - }, - { - isPaused: () => !shouldFetch, - revalidateOnFocus: false, - revalidateOnReconnect: false, - } - ); - - // Has outputs if completed, no error, and outputs object has keys - const hasOutputs = - shouldFetch && - !error && - !!data?.outputs && - Object.keys(data.outputs).length > 0; - - return hasOutputs; -} diff --git a/ui/src/features/dags/components/DAGStatus.tsx b/ui/src/features/dags/components/DAGStatus.tsx index 8489c89c..5e68efad 100644 --- a/ui/src/features/dags/components/DAGStatus.tsx +++ b/ui/src/features/dags/components/DAGStatus.tsx @@ -10,7 +10,7 @@ import { MousePointerClick, Package, } from 'lucide-react'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useCookies } from 'react-cookie'; import { useNavigate } from 'react-router-dom'; import { components, NodeStatus, Status } from '../../../api/v2/schema'; @@ -262,9 +262,15 @@ function DAGStatus({ dagRun, fileName }: Props) { }); }; - // Check if timeline should be shown (only for completed runs) - const showTimeline = - dagRun.status !== Status.NotStarted && dagRun.status !== Status.Running; + // Check if timeline should be shown (any status except not started) + const showTimeline = dagRun.status !== Status.NotStarted; + + // Reset to status tab if timeline tab is selected but not available + useEffect(() => { + if (activeTab === 'timeline' && !showTimeline) { + setActiveTab('status'); + } + }, [showTimeline, activeTab]); return (
@@ -388,9 +394,7 @@ function DAGStatus({ dagRun, fileName }: Props) { {/* Timeline Tab Content */} {activeTab === 'timeline' && showTimeline && ( - - - + )} {/* Outputs Tab Content */} diff --git a/ui/src/features/dags/components/visualization/TimelineChart.tsx b/ui/src/features/dags/components/visualization/TimelineChart.tsx index cc38de76..449a2ed0 100644 --- a/ui/src/features/dags/components/visualization/TimelineChart.tsx +++ b/ui/src/features/dags/components/visualization/TimelineChart.tsx @@ -1,70 +1,283 @@ /** - * TimelineChart component visualizes the execution timeline of a DAG dagRun using a Gantt chart. + * TimelineChart component visualizes the execution timeline of a DAG run. + * + * Features: + * - Clean horizontal bar chart showing step execution + * - Status-based color coding for each step + * - Tooltips with step details on hover + * - Works for both running and completed DAGs * * @module features/dags/components/visualization */ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import dayjs from '@/lib/dayjs'; -import Mermaid from '@/ui/Mermaid'; -import React from 'react'; -import { components, Status } from '../../../../api/v2/schema'; -import { useConfig } from '../../../../contexts/ConfigContext'; +import React, { useMemo } from 'react'; +import { components, NodeStatus } from '../../../../api/v2/schema'; +import { nodeStatusColorMapping } from '../../../../consts'; /** * Props for the TimelineChart component */ type Props = { - /** DAG dagRun details containing execution information */ + /** DAG run details containing execution information */ status: components['schemas']['DAGRunDetails']; }; -/** Format for displaying timestamps */ -const timeFormat = 'YYYY-MM-DD HH:mm:ss'; +/** Format for displaying timestamps in tooltips */ +const timeFormat = 'HH:mm:ss'; /** - * TimelineChart component renders a Gantt chart showing the execution timeline of DAG steps - * Only renders for completed DAG dagRuns (not shown for running or not started DAGs) + * Get status label for display + */ +function getStatusLabel(status: NodeStatus): string { + switch (status) { + case NodeStatus.NotStarted: + return 'Not Started'; + case NodeStatus.Running: + return 'Running'; + case NodeStatus.Success: + return 'Success'; + case NodeStatus.Failed: + return 'Failed'; + case NodeStatus.Aborted: + return 'Aborted'; + case NodeStatus.Skipped: + return 'Skipped'; + case NodeStatus.PartialSuccess: + return 'Partial Success'; + default: + return 'Unknown'; + } +} + +/** + * Calculate duration between two timestamps + */ +function calculateDuration(startMs: number, endMs: number): string { + const durationMs = endMs - startMs; + + if (durationMs < 1000) { + return `${durationMs}ms`; + } else if (durationMs < 60000) { + return `${(durationMs / 1000).toFixed(1)}s`; + } else if (durationMs < 3600000) { + const minutes = Math.floor(durationMs / 60000); + const seconds = Math.floor((durationMs % 60000) / 1000); + return `${minutes}m ${seconds}s`; + } else { + const hours = Math.floor(durationMs / 3600000); + const minutes = Math.floor((durationMs % 3600000) / 60000); + return `${hours}h ${minutes}m`; + } +} + +/** + * Get color for a node status + */ +function getStatusColor(status: NodeStatus): { bg: string; border: string } { + const mapping = nodeStatusColorMapping[status]; + if (mapping && mapping.backgroundColor) { + return { + bg: mapping.backgroundColor as string, + border: mapping.backgroundColor as string, + }; + } + return { bg: '#6b7280', border: '#6b7280' }; +} + +type TimelineItem = { + name: string; + startMs: number; + endMs: number; + status: NodeStatus; + node: components['schemas']['Node']; +}; + +/** + * TimelineChart component renders a horizontal bar chart showing step execution */ function TimelineChart({ status }: Props) { - // Get the config - const config = useConfig(); - // Don't render timeline for DAGs that haven't completed yet - if (status.status == Status.NotStarted || status.status == Status.Running) { - return null; + const { items, timelineStart, timelineEnd, timeMarkers } = useMemo(() => { + const now = Date.now(); + const validItems: TimelineItem[] = []; + + (status.nodes || []).forEach((node) => { + // Skip steps that haven't started + if (!node.startedAt || node.startedAt === '-') { + return; + } + + const startMs = dayjs(node.startedAt).valueOf(); + let endMs: number; + + // Use current time for running steps + if (!node.finishedAt || node.finishedAt === '-') { + endMs = now; + } else { + endMs = dayjs(node.finishedAt).valueOf(); + } + + // Validate + if (isNaN(startMs) || isNaN(endMs)) return; + if (endMs < startMs) endMs = startMs + 100; + + validItems.push({ + name: node.step.name, + startMs, + endMs, + status: node.status, + node, + }); + }); + + // Sort by start time + validItems.sort((a, b) => a.startMs - b.startMs); + + if (validItems.length === 0) { + return { items: [], timelineStart: 0, timelineEnd: 0, timeMarkers: [] }; + } + + // Calculate timeline bounds + const minStart = Math.min(...validItems.map((i) => i.startMs)); + const maxEnd = Math.max(...validItems.map((i) => i.endMs)); + const range = maxEnd - minStart; + const padding = Math.max(range * 0.02, 500); + + const start = minStart - padding; + const end = maxEnd + padding; + + // Generate time markers + const totalRange = end - start; + const markerCount = 5; + const markers: { position: number; label: string }[] = []; + + for (let i = 0; i <= markerCount; i++) { + const time = start + (totalRange * i) / markerCount; + markers.push({ + position: (i / markerCount) * 100, + label: dayjs(time).format(timeFormat), + }); + } + + return { + items: validItems, + timelineStart: start, + timelineEnd: end, + timeMarkers: markers, + }; + }, [status.nodes]); + + // Don't render if there are no items + if (items.length === 0) { + return ( +
+ No step execution data available. +
+ ); } - const graph = React.useMemo(() => { - const ret = [ - 'gantt', - 'dateFormat YYYY-MM-DD HH:mm:ss', - 'axisFormat %H:%M:%S', - 'todayMarker off', - ]; + const totalRange = timelineEnd - timelineStart; - // Sort nodes by start time and add them to the chart - [...status.nodes] - .sort((a, b) => { - return a.startedAt.localeCompare(b.startedAt); - }) - .forEach((step) => { - // Skip steps that haven't started - if (!step.startedAt || step.startedAt == '-') { - return; - } + return ( +
+ {/* Time axis header */} +
+ {timeMarkers.map((marker, idx) => ( +
+ + {marker.label} + +
+ ))} +
- // Add step to the Gantt chart with start and end times - ret.push( - step.step.name + - ' : ' + - dayjs(step.startedAt).format(timeFormat) + - ',' + - dayjs(step.finishedAt).format(timeFormat) - ); - }); + {/* Timeline rows */} +
+ {/* Grid lines */} +
+ {timeMarkers.map((marker, idx) => ( +
+ ))} +
- return ret.join('\n'); - }, [status, config.tz]); + {/* Step rows */} + {items.map((item, idx) => { + const leftPercent = ((item.startMs - timelineStart) / totalRange) * 100; + const widthPercent = ((item.endMs - item.startMs) / totalRange) * 100; + const colors = getStatusColor(item.status); + const isRunning = item.status === NodeStatus.Running; - return ; + return ( +
+ {/* Step name label */} +
+ {item.name} +
+ + {/* Timeline bar */} + + +
+ + +
+
{item.name}
+ {item.node.step.description && ( +
+ {item.node.step.description} +
+ )} +
+ Status: {getStatusLabel(item.status)} +
+
+ Duration: {calculateDuration(item.startMs, item.endMs)} +
+
+ {dayjs(item.startMs).format('HH:mm:ss')} → {dayjs(item.endMs).format('HH:mm:ss')} +
+ {item.node.error && ( +
+ Error: {item.node.error} +
+ )} +
+
+ +
+ ); + })} +
+
+ ); } export default TimelineChart;