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;