improve timeline tab content

This commit is contained in:
Yota Hamada 2025-12-28 03:25:29 +09:00
parent 1f202d6624
commit e8c3f4dcf7
3 changed files with 268 additions and 98 deletions

View File

@ -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;
}

View File

@ -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 (
<div className="space-y-4">
@ -388,9 +394,7 @@ function DAGStatus({ dagRun, fileName }: Props) {
{/* Timeline Tab Content */}
{activeTab === 'timeline' && showTimeline && (
<BorderedBox className="py-4 px-4 overflow-x-auto">
<TimelineChart status={dagRun} />
</BorderedBox>
<TimelineChart status={dagRun} />
)}
{/* Outputs Tab Content */}

View File

@ -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 (
<div className="text-sm text-muted-foreground p-4">
No step execution data available.
</div>
);
}
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 (
<div className="w-full bg-card rounded-md border border-border overflow-hidden">
{/* Time axis header */}
<div className="relative h-6 bg-muted border-b border-border">
{timeMarkers.map((marker, idx) => (
<div
key={idx}
className="absolute top-0 h-full flex items-center"
style={{ left: `${marker.position}%`, transform: 'translateX(-50%)' }}
>
<span className="text-[10px] text-muted-foreground font-mono">
{marker.label}
</span>
</div>
))}
</div>
// 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 */}
<div className="relative">
{/* Grid lines */}
<div className="absolute inset-0 pointer-events-none">
{timeMarkers.map((marker, idx) => (
<div
key={idx}
className="absolute top-0 bottom-0 border-l border-border/30"
style={{ left: `${marker.position}%` }}
/>
))}
</div>
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 <Mermaid def={graph} scale={1.0} />;
return (
<div
key={item.name}
className={`relative h-8 flex items-center ${
idx % 2 === 0 ? 'bg-background' : 'bg-muted/20'
}`}
>
{/* Step name label */}
<div className="absolute left-2 z-10 text-xs font-medium text-foreground truncate max-w-[120px]">
{item.name}
</div>
{/* Timeline bar */}
<Tooltip>
<TooltipTrigger asChild>
<div
className={`absolute h-5 rounded cursor-pointer transition-opacity hover:opacity-80 ${
isRunning ? 'animate-pulse' : ''
}`}
style={{
left: `calc(${leftPercent}% + 130px)`,
width: `calc(${Math.max(widthPercent, 0.5)}% - 130px)`,
minWidth: '4px',
backgroundColor: colors.bg,
borderLeft: `2px solid ${colors.border}`,
}}
/>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1">
<div className="font-semibold">{item.name}</div>
{item.node.step.description && (
<div className="text-xs text-muted-foreground">
{item.node.step.description}
</div>
)}
<div className="text-xs">
Status: <span className="font-medium">{getStatusLabel(item.status)}</span>
</div>
<div className="text-xs">
Duration: <span className="font-mono">{calculateDuration(item.startMs, item.endMs)}</span>
</div>
<div className="text-xs text-muted-foreground">
{dayjs(item.startMs).format('HH:mm:ss')} {dayjs(item.endMs).format('HH:mm:ss')}
</div>
{item.node.error && (
<div className="text-xs text-destructive">
Error: {item.node.error}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</div>
);
})}
</div>
</div>
);
}
export default TimelineChart;