mirror of
https://github.com/dagu-org/dagu.git
synced 2025-12-28 06:34:22 +00:00
improve timeline tab content
This commit is contained in:
parent
1f202d6624
commit
e8c3f4dcf7
@ -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;
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user