working on workflows
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* TimelineModal — Full-screen modal for the Workflow Timeline DAG.
|
||||
*
|
||||
* Opens as a portal overlay with:
|
||||
* - A much larger vertical layout (more lane height, bigger bars)
|
||||
* - A timescale zoom slider that re-computes the layout at wider widths
|
||||
* - Horizontal scroll for zoomed-in views
|
||||
* - All the same interactions as the inline renderer (hover, click, double-click)
|
||||
* - Escape key / close button to dismiss
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, ZoomIn, ZoomOut, RotateCcw, GitBranch } from "lucide-react";
|
||||
|
||||
import type {
|
||||
TimelineTask,
|
||||
TimelineEdge,
|
||||
TimelineMilestone,
|
||||
LayoutConfig,
|
||||
ComputedLayout,
|
||||
} from "./types";
|
||||
import { DEFAULT_LAYOUT } from "./types";
|
||||
import { computeLayout } from "./layout";
|
||||
import TimelineRenderer from "./TimelineRenderer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TimelineModalProps {
|
||||
/** Whether the modal is open */
|
||||
isOpen: boolean;
|
||||
/** Callback to close the modal */
|
||||
onClose: () => void;
|
||||
/** Timeline tasks */
|
||||
tasks: TimelineTask[];
|
||||
/** Structural dependency edges between tasks */
|
||||
taskEdges: TimelineEdge[];
|
||||
/** Synthetic milestone nodes */
|
||||
milestones: TimelineMilestone[];
|
||||
/** Edges connecting milestones */
|
||||
milestoneEdges: TimelineEdge[];
|
||||
/** Direct task→task edge keys replaced by milestone-routed paths */
|
||||
suppressedEdgeKeys?: Set<string>;
|
||||
/** Callback when a task is double-clicked (navigate to execution) */
|
||||
onTaskClick?: (task: TimelineTask) => void;
|
||||
/** Summary stats for the header */
|
||||
summary: {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
running: number;
|
||||
other: number;
|
||||
durationMs: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The modal layout uses more generous spacing */
|
||||
const MODAL_LAYOUT: LayoutConfig = {
|
||||
...DEFAULT_LAYOUT,
|
||||
laneHeight: 44,
|
||||
barHeight: 28,
|
||||
lanePadding: 8,
|
||||
milestoneSize: 12,
|
||||
paddingTop: 44,
|
||||
paddingBottom: 24,
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
minBarWidth: 12,
|
||||
};
|
||||
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 8;
|
||||
const ZOOM_STEP = 0.25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TimelineModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tasks,
|
||||
taskEdges,
|
||||
milestones,
|
||||
milestoneEdges,
|
||||
suppressedEdgeKeys,
|
||||
onTaskClick,
|
||||
summary,
|
||||
}: TimelineModalProps) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
|
||||
// ---- Observe container width ----
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Initial measurement
|
||||
setContainerWidth(el.clientWidth);
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.width > 0) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [isOpen]);
|
||||
|
||||
// ---- Keyboard handling (Escape to close) ----
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// ---- Prevent body scroll when modal is open ----
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// ---- Adjust layout config based on task count ----
|
||||
const layoutConfig: LayoutConfig = useMemo(() => {
|
||||
const taskCount = tasks.length;
|
||||
if (taskCount > 80) {
|
||||
return {
|
||||
...MODAL_LAYOUT,
|
||||
laneHeight: 32,
|
||||
barHeight: 20,
|
||||
lanePadding: 6,
|
||||
};
|
||||
}
|
||||
if (taskCount > 40) {
|
||||
return {
|
||||
...MODAL_LAYOUT,
|
||||
laneHeight: 38,
|
||||
barHeight: 24,
|
||||
lanePadding: 7,
|
||||
};
|
||||
}
|
||||
return MODAL_LAYOUT;
|
||||
}, [tasks.length]);
|
||||
|
||||
// ---- Compute layout at the zoomed width ----
|
||||
const layout: ComputedLayout | null = useMemo(() => {
|
||||
if (tasks.length === 0) return null;
|
||||
// Zoom stretches the timeline horizontally
|
||||
const effectiveWidth = Math.max(containerWidth * zoom, 600);
|
||||
return computeLayout(
|
||||
tasks,
|
||||
taskEdges,
|
||||
milestones,
|
||||
milestoneEdges,
|
||||
effectiveWidth,
|
||||
layoutConfig,
|
||||
suppressedEdgeKeys,
|
||||
);
|
||||
}, [
|
||||
tasks,
|
||||
taskEdges,
|
||||
milestones,
|
||||
milestoneEdges,
|
||||
containerWidth,
|
||||
zoom,
|
||||
layoutConfig,
|
||||
suppressedEdgeKeys,
|
||||
]);
|
||||
|
||||
// ---- Zoom handlers ----
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP));
|
||||
}, []);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
setZoom(1);
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollLeft = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomSlider = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setZoom(parseFloat(e.target.value));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ---- Wheel zoom on the timeline area ----
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Only zoom on Ctrl+wheel or meta+wheel to avoid interfering with normal scroll
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
setZoom((z) => {
|
||||
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z + delta));
|
||||
return newZoom;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col"
|
||||
style={{ backgroundColor: "rgba(0, 0, 0, 0.6)" }}
|
||||
onClick={(e) => {
|
||||
// Close on backdrop click
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Modal container */}
|
||||
<div className="flex flex-col m-4 md:m-6 lg:m-8 bg-white rounded-xl shadow-2xl overflow-hidden flex-1 min-h-0">
|
||||
{/* ---- Header ---- */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 bg-gray-50/80 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<GitBranch className="h-4 w-4 text-indigo-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-800">
|
||||
Workflow Timeline
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
{summary.total} task{summary.total !== 1 ? "s" : ""}
|
||||
{summary.durationMs != null && (
|
||||
<> · {formatDurationShort(summary.durationMs)}</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
{summary.completed > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-100 text-green-700">
|
||||
{summary.completed} ✓
|
||||
</span>
|
||||
)}
|
||||
{summary.running > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-blue-100 text-blue-700">
|
||||
{summary.running} ⟳
|
||||
</span>
|
||||
)}
|
||||
{summary.failed > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-red-100 text-red-700">
|
||||
{summary.failed} ✗
|
||||
</span>
|
||||
)}
|
||||
{summary.other > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-500">
|
||||
{summary.other}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: zoom controls + close */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-2.5 py-1.5 shadow-sm">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
className="p-0.5 text-gray-500 hover:text-gray-800 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_ZOOM}
|
||||
max={MAX_ZOOM}
|
||||
step={ZOOM_STEP}
|
||||
value={zoom}
|
||||
onChange={handleZoomSlider}
|
||||
className="w-24 h-1 accent-indigo-500 cursor-pointer"
|
||||
title={`Timescale: ${Math.round(zoom * 100)}%`}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
className="p-0.5 text-gray-500 hover:text-gray-800 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-gray-500 font-mono tabular-nums w-10 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
|
||||
{zoom !== 1 && (
|
||||
<button
|
||||
onClick={handleZoomReset}
|
||||
className="p-0.5 text-gray-400 hover:text-gray-700"
|
||||
title="Reset zoom"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Legend ---- */}
|
||||
<div className="flex items-center gap-3 px-5 py-2 text-[10px] text-gray-400 border-b border-gray-100 flex-shrink-0">
|
||||
<LegendItem color="#22c55e" label="Completed" />
|
||||
<LegendItem color="#3b82f6" label="Running" />
|
||||
<LegendItem color="#ef4444" label="Failed" dashed />
|
||||
<LegendItem color="#f97316" label="Timeout" dotted />
|
||||
<LegendItem color="#9ca3af" label="Pending" />
|
||||
<span className="ml-2 text-gray-300">|</span>
|
||||
<EdgeLegendItem color="#22c55e" label="Succeeded" />
|
||||
<EdgeLegendItem color="#ef4444" label="Failed" dashed />
|
||||
<EdgeLegendItem color="#9ca3af" label="Always" />
|
||||
<span className="ml-auto text-gray-300">
|
||||
Ctrl+scroll to zoom · Click task to highlight path · Double-click to
|
||||
view
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ---- Timeline body ---- */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 min-h-0 overflow-auto"
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{layout ? (
|
||||
<div ref={scrollRef} className="min-h-full">
|
||||
<TimelineRenderer
|
||||
layout={layout}
|
||||
tasks={tasks}
|
||||
config={layoutConfig}
|
||||
onTaskClick={onTaskClick}
|
||||
idPrefix="modal-"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-gray-400">No tasks to display</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legend sub-components (duplicated from WorkflowTimelineDAG to keep modal
|
||||
// self-contained — these are tiny presentational helpers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LegendItem({
|
||||
color,
|
||||
label,
|
||||
dashed,
|
||||
dotted,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
dashed?: boolean;
|
||||
dotted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-5 h-2.5 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
opacity: 0.7,
|
||||
border: dashed
|
||||
? `1px dashed ${color}`
|
||||
: dotted
|
||||
? `1px dotted ${color}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EdgeLegendItem({
|
||||
color,
|
||||
label,
|
||||
dashed,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
dashed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="16" height="8" viewBox="0 0 16 8">
|
||||
<line
|
||||
x1="0"
|
||||
y1="4"
|
||||
x2="16"
|
||||
y2="4"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={dashed ? "3 2" : undefined}
|
||||
opacity="0.7"
|
||||
/>
|
||||
<polygon points="12,1 16,4 12,7" fill={color} opacity="0.6" />
|
||||
</svg>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatDurationShort(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
const secs = ms / 1000;
|
||||
if (secs < 60) return `${secs.toFixed(1)}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remainSecs = Math.round(secs % 60);
|
||||
if (mins < 60) return `${mins}m ${remainSecs}s`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
return `${hrs}h ${remainMins}m`;
|
||||
}
|
||||
1053
web/src/components/executions/workflow-timeline/TimelineRenderer.tsx
Normal file
1053
web/src/components/executions/workflow-timeline/TimelineRenderer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* WorkflowTimelineDAG — Orchestrator component for the Prefect-style
|
||||
* workflow run timeline visualization.
|
||||
*
|
||||
* This component:
|
||||
* 1. Fetches the workflow definition (for transition metadata)
|
||||
* 2. Transforms child execution summaries into timeline structures
|
||||
* 3. Computes the DAG layout (lanes, positions, edges)
|
||||
* 4. Delegates rendering to TimelineRenderer
|
||||
*
|
||||
* It is designed to be embedded in the ExecutionDetailPage for workflow
|
||||
* executions, receiving child execution data from the parent.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useCallback, useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ExecutionSummary } from "@/api";
|
||||
import { useWorkflow } from "@/hooks/useWorkflows";
|
||||
import { useChildExecutions } from "@/hooks/useExecutions";
|
||||
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
||||
import {
|
||||
ChartGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
|
||||
import type {
|
||||
TimelineTask,
|
||||
TimelineEdge,
|
||||
TimelineMilestone,
|
||||
WorkflowDefinition,
|
||||
LayoutConfig,
|
||||
} from "./types";
|
||||
import { DEFAULT_LAYOUT } from "./types";
|
||||
import {
|
||||
buildTimelineTasks,
|
||||
collapseWithItemsGroups,
|
||||
buildEdges,
|
||||
buildMilestones,
|
||||
} from "./data";
|
||||
import { computeLayout } from "./layout";
|
||||
import TimelineRenderer from "./TimelineRenderer";
|
||||
import TimelineModal from "./TimelineModal";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal parent execution shape accepted by this component.
|
||||
// Both ExecutionResponse and ExecutionSummary satisfy this interface,
|
||||
// so callers don't need an ugly cast.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParentExecutionInfo {
|
||||
id: number;
|
||||
action_ref: string;
|
||||
status: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
started_at?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WorkflowTimelineDAGProps {
|
||||
/** The parent (workflow) execution — accepts ExecutionResponse or ExecutionSummary */
|
||||
parentExecution: ParentExecutionInfo;
|
||||
/** The action_ref of the parent execution (used to fetch workflow def) */
|
||||
actionRef: string;
|
||||
/** Whether the panel starts collapsed */
|
||||
defaultCollapsed?: boolean;
|
||||
/**
|
||||
* When true, renders only the timeline content (legend, renderer, modal)
|
||||
* without the outer card wrapper, header button, or collapse toggle.
|
||||
* Used when the component is embedded inside another panel (e.g. WorkflowDetailsPanel).
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function WorkflowTimelineGraph({
|
||||
parentExecution,
|
||||
actionRef,
|
||||
defaultCollapsed = false,
|
||||
embedded = false,
|
||||
}: WorkflowTimelineDAGProps) {
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
embedded ? false : defaultCollapsed,
|
||||
);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(900);
|
||||
const [nowMs, setNowMs] = useState(Date.now);
|
||||
|
||||
// ---- Determine if the workflow is still in-flight ----
|
||||
const isTerminal = [
|
||||
"completed",
|
||||
"failed",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
"abandoned",
|
||||
].includes(parentExecution.status);
|
||||
|
||||
// ---- Smooth animation via requestAnimationFrame ----
|
||||
// While the workflow is running and the panel is visible, tick at display
|
||||
// refresh rate (~60fps) so running task bars and the time axis grow smoothly.
|
||||
useEffect(() => {
|
||||
if (isTerminal || (!embedded && isCollapsed)) return;
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
setNowMs(Date.now());
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isTerminal, isCollapsed, embedded]);
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
// Fetch child executions
|
||||
const { data: childData, isLoading: childrenLoading } = useChildExecutions(
|
||||
parentExecution.id,
|
||||
);
|
||||
|
||||
// Subscribe to real-time execution updates so child tasks update live
|
||||
useExecutionStream({ enabled: true });
|
||||
|
||||
// Fetch workflow definition for transition metadata
|
||||
// The workflow ref matches the action ref for workflow actions
|
||||
const { data: workflowData } = useWorkflow(actionRef);
|
||||
|
||||
const childExecutions: ExecutionSummary[] = useMemo(() => {
|
||||
return childData?.data ?? [];
|
||||
}, [childData]);
|
||||
|
||||
const workflowDef: WorkflowDefinition | null = useMemo(() => {
|
||||
if (!workflowData?.data?.definition) return null;
|
||||
return workflowData.data.definition as WorkflowDefinition;
|
||||
}, [workflowData]);
|
||||
|
||||
// ---- Observe container width for responsive layout ----
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
if (w > 0) setContainerWidth(w);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [isCollapsed]);
|
||||
|
||||
// ---- Build timeline data structures ----
|
||||
// Split into two phases:
|
||||
// 1. Structural memo — edges and upstream/downstream links. These depend
|
||||
// only on the set of child executions and the workflow definition, NOT
|
||||
// on the current time. Recomputes only when real data changes.
|
||||
// 2. Per-frame memo — task time positions, milestones, and layout. These
|
||||
// depend on `nowMs` so they update every animation frame (~60fps) while
|
||||
// the workflow is running, giving smooth bar growth.
|
||||
|
||||
// Phase 1: Build tasks (without time-dependent endMs) and compute edges.
|
||||
// `buildEdges` mutates tasks' upstreamIds/downstreamIds, so we must call
|
||||
// it in the same memo that creates the task objects.
|
||||
const { structuralTasks, taskEdges } = useMemo(() => {
|
||||
if (childExecutions.length === 0) {
|
||||
return {
|
||||
structuralTasks: [] as TimelineTask[],
|
||||
taskEdges: [] as TimelineEdge[],
|
||||
};
|
||||
}
|
||||
|
||||
// Build individual tasks, then collapse large with_items groups into
|
||||
// single synthetic nodes before computing edges.
|
||||
const rawTasks = buildTimelineTasks(childExecutions, workflowDef);
|
||||
const { tasks: structuralTasks, memberToGroup } = collapseWithItemsGroups(
|
||||
rawTasks,
|
||||
childExecutions,
|
||||
workflowDef,
|
||||
);
|
||||
|
||||
// Derive dependency edges (purely structural — no time dependency).
|
||||
// Pass the collapse mapping so edges redirect to group nodes.
|
||||
const taskEdges = buildEdges(
|
||||
structuralTasks,
|
||||
childExecutions,
|
||||
workflowDef,
|
||||
memberToGroup,
|
||||
);
|
||||
|
||||
return { structuralTasks, taskEdges };
|
||||
}, [childExecutions, workflowDef]);
|
||||
|
||||
// Phase 2: Patch running-task time positions and build milestones.
|
||||
// This runs every animation frame while the workflow is active.
|
||||
const { tasks, milestones, milestoneEdges, suppressedEdgeKeys } =
|
||||
useMemo(() => {
|
||||
if (structuralTasks.length === 0) {
|
||||
return {
|
||||
tasks: [] as TimelineTask[],
|
||||
milestones: [] as TimelineMilestone[],
|
||||
milestoneEdges: [] as TimelineEdge[],
|
||||
suppressedEdgeKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
// Patch endMs / durationMs for running tasks so bars grow in real time.
|
||||
// We shallow-clone each task that needs updating to keep React diffing
|
||||
// efficient (unchanged tasks keep the same object identity).
|
||||
const tasks = structuralTasks.map((t) => {
|
||||
if (t.state === "running" && t.startMs != null) {
|
||||
const endMs = nowMs;
|
||||
return { ...t, endMs, durationMs: endMs - t.startMs };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
|
||||
// Build milestones (start/end diamonds, merge/fork junctions)
|
||||
const parentAsSummary: ExecutionSummary = {
|
||||
id: parentExecution.id,
|
||||
action_ref: parentExecution.action_ref,
|
||||
status: parentExecution.status as ExecutionSummary["status"],
|
||||
created: parentExecution.created,
|
||||
updated: parentExecution.updated,
|
||||
started_at: parentExecution.started_at,
|
||||
};
|
||||
const { milestones, milestoneEdges, suppressedEdgeKeys } =
|
||||
buildMilestones(tasks, parentAsSummary);
|
||||
|
||||
return { tasks, milestones, milestoneEdges, suppressedEdgeKeys };
|
||||
}, [structuralTasks, parentExecution, nowMs]);
|
||||
|
||||
// ---- Compute layout ----
|
||||
|
||||
const layoutConfig: LayoutConfig = useMemo(() => {
|
||||
// Adjust layout based on task count for readability
|
||||
const taskCount = tasks.length;
|
||||
if (taskCount > 50) {
|
||||
return {
|
||||
...DEFAULT_LAYOUT,
|
||||
laneHeight: 26,
|
||||
barHeight: 16,
|
||||
lanePadding: 5,
|
||||
};
|
||||
}
|
||||
if (taskCount > 20) {
|
||||
return {
|
||||
...DEFAULT_LAYOUT,
|
||||
laneHeight: 30,
|
||||
barHeight: 18,
|
||||
lanePadding: 6,
|
||||
};
|
||||
}
|
||||
return DEFAULT_LAYOUT;
|
||||
}, [tasks.length]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
return computeLayout(
|
||||
tasks,
|
||||
taskEdges,
|
||||
milestones,
|
||||
milestoneEdges,
|
||||
containerWidth,
|
||||
layoutConfig,
|
||||
suppressedEdgeKeys,
|
||||
);
|
||||
}, [
|
||||
tasks,
|
||||
taskEdges,
|
||||
milestones,
|
||||
milestoneEdges,
|
||||
containerWidth,
|
||||
layoutConfig,
|
||||
suppressedEdgeKeys,
|
||||
]);
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(task: TimelineTask) => {
|
||||
navigate(`/executions/${task.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// ---- Summary stats ----
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = childExecutions.length;
|
||||
const completed = childExecutions.filter(
|
||||
(e) => e.status === "completed",
|
||||
).length;
|
||||
const failed = childExecutions.filter((e) => e.status === "failed").length;
|
||||
const running = childExecutions.filter(
|
||||
(e) =>
|
||||
e.status === "running" ||
|
||||
e.status === "requested" ||
|
||||
e.status === "scheduling" ||
|
||||
e.status === "scheduled",
|
||||
).length;
|
||||
const other = total - completed - failed - running;
|
||||
|
||||
// Compute overall duration from the already-patched tasks array so we
|
||||
// get the live running-task endMs values for free.
|
||||
let durationMs: number | null = null;
|
||||
const taskStartTimes = tasks
|
||||
.filter((t) => t.startMs != null)
|
||||
.map((t) => t.startMs!);
|
||||
const taskEndTimes = tasks
|
||||
.filter((t) => t.endMs != null)
|
||||
.map((t) => t.endMs!);
|
||||
|
||||
if (taskStartTimes.length > 0 && taskEndTimes.length > 0) {
|
||||
durationMs = Math.max(...taskEndTimes) - Math.min(...taskStartTimes);
|
||||
}
|
||||
|
||||
return { total, completed, failed, running, other, durationMs };
|
||||
}, [childExecutions, tasks]);
|
||||
|
||||
// ---- Early returns ----
|
||||
|
||||
if (childrenLoading && childExecutions.length === 0) {
|
||||
return (
|
||||
<div className={embedded ? "" : "bg-white shadow rounded-lg"}>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span className="text-sm text-gray-500">
|
||||
Loading workflow timeline…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (childExecutions.length === 0) {
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-gray-500">
|
||||
No workflow tasks yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null; // No child tasks to display
|
||||
}
|
||||
|
||||
// ---- Shared content (legend + renderer + modal) ----
|
||||
const timelineContent = (
|
||||
<>
|
||||
{/* Expand to modal */}
|
||||
<div className="flex justify-end px-3 py-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1 text-[10px] text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Open expanded timeline with zoom"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 px-5 pb-2 text-[10px] text-gray-400">
|
||||
<LegendItem color="#22c55e" label="Completed" />
|
||||
<LegendItem color="#3b82f6" label="Running" />
|
||||
<LegendItem color="#ef4444" label="Failed" dashed />
|
||||
<LegendItem color="#f97316" label="Timeout" dotted />
|
||||
<LegendItem color="#9ca3af" label="Pending" />
|
||||
<span className="ml-2 text-gray-300">|</span>
|
||||
<EdgeLegendItem color="#22c55e" label="Succeeded" />
|
||||
<EdgeLegendItem color="#ef4444" label="Failed" dashed />
|
||||
<EdgeLegendItem color="#9ca3af" label="Always" />
|
||||
</div>
|
||||
|
||||
{/* Timeline renderer */}
|
||||
{layout ? (
|
||||
<div
|
||||
className={embedded ? "pb-3" : "px-2 pb-3"}
|
||||
style={{
|
||||
minHeight: layout.totalHeight + 8,
|
||||
}}
|
||||
>
|
||||
<TimelineRenderer
|
||||
layout={layout}
|
||||
tasks={tasks}
|
||||
config={layoutConfig}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-300" />
|
||||
<span className="ml-2 text-xs text-gray-400">Computing layout…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Expanded modal ---- */}
|
||||
{isModalOpen && (
|
||||
<TimelineModal
|
||||
isOpen
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
tasks={tasks}
|
||||
taskEdges={taskEdges}
|
||||
milestones={milestones}
|
||||
milestoneEdges={milestoneEdges}
|
||||
suppressedEdgeKeys={suppressedEdgeKeys}
|
||||
onTaskClick={handleTaskClick}
|
||||
summary={summary}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// ---- Embedded mode: no card, no header, just the content ----
|
||||
if (embedded) {
|
||||
return (
|
||||
<div ref={containerRef} className="pt-1">
|
||||
{timelineContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Standalone mode: full card with header + collapse ----
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg" ref={containerRef}>
|
||||
{/* ---- Header ---- */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-gray-50 rounded-t-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<ChartGantt className="h-4 w-4 text-indigo-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Workflow Timeline
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">
|
||||
{summary.total} task{summary.total !== 1 ? "s" : ""}
|
||||
{summary.durationMs != null && (
|
||||
<> · {formatDurationShort(summary.durationMs)}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{summary.completed > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-100 text-green-700">
|
||||
{summary.completed} ✓
|
||||
</span>
|
||||
)}
|
||||
{summary.running > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-blue-100 text-blue-700">
|
||||
{summary.running} ⟳
|
||||
</span>
|
||||
)}
|
||||
{summary.failed > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-red-100 text-red-700">
|
||||
{summary.failed} ✗
|
||||
</span>
|
||||
)}
|
||||
{summary.other > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-500">
|
||||
{summary.other}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* ---- Body ---- */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-gray-100">{timelineContent}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legend sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LegendItem({
|
||||
color,
|
||||
label,
|
||||
dashed,
|
||||
dotted,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
dashed?: boolean;
|
||||
dotted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-5 h-2.5 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
opacity: 0.7,
|
||||
border: dashed
|
||||
? `1px dashed ${color}`
|
||||
: dotted
|
||||
? `1px dotted ${color}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EdgeLegendItem({
|
||||
color,
|
||||
label,
|
||||
dashed,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
dashed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="16" height="8" viewBox="0 0 16 8">
|
||||
<line
|
||||
x1="0"
|
||||
y1="4"
|
||||
x2="16"
|
||||
y2="4"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={dashed ? "3 2" : undefined}
|
||||
opacity="0.7"
|
||||
/>
|
||||
<polygon points="12,1 16,4 12,7" fill={color} opacity="0.6" />
|
||||
</svg>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatDurationShort(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
const secs = ms / 1000;
|
||||
if (secs < 60) return `${secs.toFixed(1)}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remainSecs = Math.round(secs % 60);
|
||||
if (mins < 60) return `${mins}m ${remainSecs}s`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
return `${hrs}h ${remainMins}m`;
|
||||
}
|
||||
1376
web/src/components/executions/workflow-timeline/data.ts
Normal file
1376
web/src/components/executions/workflow-timeline/data.ts
Normal file
File diff suppressed because it is too large
Load Diff
41
web/src/components/executions/workflow-timeline/index.ts
Normal file
41
web/src/components/executions/workflow-timeline/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Workflow Timeline DAG — barrel exports.
|
||||
*
|
||||
* Usage:
|
||||
* import WorkflowTimelineDAG from "@/components/executions/workflow-timeline";
|
||||
*/
|
||||
|
||||
export { default } from "./WorkflowTimelineGraph";
|
||||
export type { ParentExecutionInfo } from "./WorkflowTimelineGraph";
|
||||
export { default as TimelineRenderer } from "./TimelineRenderer";
|
||||
export { default as TimelineModal } from "./TimelineModal";
|
||||
|
||||
// Re-export types consumers might need
|
||||
export type {
|
||||
TimelineTask,
|
||||
TimelineEdge,
|
||||
TimelineMilestone,
|
||||
TimelineNode,
|
||||
ComputedLayout,
|
||||
TaskState,
|
||||
EdgeKind,
|
||||
MilestoneKind,
|
||||
TooltipData,
|
||||
LayoutConfig,
|
||||
WorkflowDefinition,
|
||||
WithItemsGroupInfo,
|
||||
} from "./types";
|
||||
|
||||
export { WITH_ITEMS_COLLAPSE_THRESHOLD } from "./types";
|
||||
|
||||
// Re-export data utilities for testing / advanced usage
|
||||
export {
|
||||
buildTimelineTasks,
|
||||
buildEdges,
|
||||
buildMilestones,
|
||||
findConnectedPath,
|
||||
edgeKey,
|
||||
} from "./data";
|
||||
|
||||
// Re-export layout utilities
|
||||
export { computeLayout, computeGridLines, computeEdgePath } from "./layout";
|
||||
673
web/src/components/executions/workflow-timeline/layout.ts
Normal file
673
web/src/components/executions/workflow-timeline/layout.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Layout Engine for the Workflow Timeline DAG.
|
||||
*
|
||||
* Responsible for:
|
||||
* 1. Computing the time→pixel x-scale from task time bounds.
|
||||
* 2. Assigning tasks to non-overlapping y-lanes (greedy packing).
|
||||
* 3. Positioning milestone nodes.
|
||||
* 4. Producing the final ComputedLayout consumed by the SVG renderer.
|
||||
*/
|
||||
|
||||
import type {
|
||||
TimelineTask,
|
||||
TimelineEdge,
|
||||
TimelineMilestone,
|
||||
TimelineNode,
|
||||
ComputedLayout,
|
||||
LayoutConfig,
|
||||
} from "./types";
|
||||
import { DEFAULT_LAYOUT } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time scale helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TimeScale {
|
||||
/** Minimum time (epoch ms) */
|
||||
minMs: number;
|
||||
/** Maximum time (epoch ms) */
|
||||
maxMs: number;
|
||||
/** Available pixel width for the time axis */
|
||||
axisWidth: number;
|
||||
/** Pixels per millisecond */
|
||||
pxPerMs: number;
|
||||
}
|
||||
|
||||
function buildTimeScale(
|
||||
tasks: TimelineTask[],
|
||||
milestones: TimelineMilestone[],
|
||||
chartWidth: number,
|
||||
config: LayoutConfig,
|
||||
): TimeScale {
|
||||
// Collect all time values
|
||||
const times: number[] = [];
|
||||
for (const t of tasks) {
|
||||
if (t.startMs != null) times.push(t.startMs);
|
||||
if (t.endMs != null) times.push(t.endMs);
|
||||
}
|
||||
for (const m of milestones) {
|
||||
times.push(m.timeMs);
|
||||
}
|
||||
|
||||
if (times.length === 0) {
|
||||
// Fallback: a 10-second window around now
|
||||
const now = Date.now();
|
||||
times.push(now - 5000, now + 5000);
|
||||
}
|
||||
|
||||
let minMs = Math.min(...times);
|
||||
let maxMs = Math.max(...times);
|
||||
|
||||
// Add a small buffer so nodes at the edges aren't right on the border
|
||||
const rangeMs = maxMs - minMs;
|
||||
const bufferMs = Math.max(rangeMs * 0.04, 200); // at least 200ms buffer
|
||||
minMs -= bufferMs;
|
||||
maxMs += bufferMs;
|
||||
|
||||
const axisWidth = chartWidth - config.paddingLeft - config.paddingRight;
|
||||
const pxPerMs = axisWidth / Math.max(maxMs - minMs, 1);
|
||||
|
||||
return { minMs, maxMs, axisWidth, pxPerMs };
|
||||
}
|
||||
|
||||
/** Convert a timestamp (epoch ms) to an x pixel position */
|
||||
function timeToPx(ms: number, scale: TimeScale, config: LayoutConfig): number {
|
||||
return config.paddingLeft + (ms - scale.minMs) * scale.pxPerMs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lane assignment (greedy packing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LaneInterval {
|
||||
/** Left x pixel (inclusive) */
|
||||
left: number;
|
||||
/** Right x pixel (inclusive) */
|
||||
right: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each task to the first lane where it doesn't overlap with
|
||||
* any existing task bar in that lane.
|
||||
*
|
||||
* Tasks are sorted by startTime (earliest first), then by duration
|
||||
* descending (longer bars first) to maximise packing efficiency.
|
||||
*
|
||||
* After initial packing we optionally reorder lanes so tasks with
|
||||
* shared upstream dependencies are adjacent.
|
||||
*/
|
||||
function assignLanes(
|
||||
tasks: TimelineTask[],
|
||||
scale: TimeScale,
|
||||
config: LayoutConfig,
|
||||
): Map<string, number> {
|
||||
// Build a sortable list with pixel extents
|
||||
type Entry = {
|
||||
task: TimelineTask;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
|
||||
const entries: Entry[] = tasks.map((t) => {
|
||||
const left = t.startMs != null ? timeToPx(t.startMs, scale, config) : 0;
|
||||
let right =
|
||||
t.endMs != null
|
||||
? timeToPx(t.endMs, scale, config)
|
||||
: left + config.minBarWidth;
|
||||
// Ensure minimum width
|
||||
if (right - left < config.minBarWidth) {
|
||||
right = left + config.minBarWidth;
|
||||
}
|
||||
return { task: t, left, right };
|
||||
});
|
||||
|
||||
// Sort: by start position, then by width descending (longer bars first)
|
||||
entries.sort((a, b) => {
|
||||
if (a.left !== b.left) return a.left - b.left;
|
||||
return b.right - b.left - (a.right - a.left);
|
||||
});
|
||||
|
||||
// Greedy lane packing
|
||||
const lanes: LaneInterval[][] = []; // lanes[laneIndex] = list of intervals
|
||||
const assignment = new Map<string, number>();
|
||||
|
||||
for (const entry of entries) {
|
||||
let placed = false;
|
||||
const gap = 4; // minimum px gap between bars in the same lane
|
||||
|
||||
for (let lane = 0; lane < lanes.length; lane++) {
|
||||
const intervals = lanes[lane];
|
||||
const overlaps = intervals.some(
|
||||
(iv) => entry.left < iv.right + gap && entry.right + gap > iv.left,
|
||||
);
|
||||
if (!overlaps) {
|
||||
intervals.push({ left: entry.left, right: entry.right });
|
||||
assignment.set(entry.task.id, lane);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
// Open a new lane
|
||||
lanes.push([{ left: entry.left, right: entry.right }]);
|
||||
assignment.set(entry.task.id, lanes.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Optional lane reordering to cluster related tasks ---
|
||||
// Build a lane affinity score based on shared upstream dependencies.
|
||||
// We do a simple bubble-pass: for each pair of adjacent lanes,
|
||||
// if swapping them increases the total number of adjacent upstream-sharing
|
||||
// task pairs, do the swap.
|
||||
const laneCount = lanes.length;
|
||||
if (laneCount > 2) {
|
||||
const laneIds: number[] = Array.from({ length: laneCount }, (_, i) => i);
|
||||
|
||||
// Build lane→taskIds mapping
|
||||
const tasksByLane = new Map<number, string[]>();
|
||||
for (const [taskId, lane] of assignment) {
|
||||
const list = tasksByLane.get(lane) ?? [];
|
||||
list.push(taskId);
|
||||
tasksByLane.set(lane, list);
|
||||
}
|
||||
|
||||
// Build a task→upstreams lookup
|
||||
const taskUpstreams = new Map<string, Set<string>>();
|
||||
for (const t of tasks) {
|
||||
taskUpstreams.set(t.id, new Set(t.upstreamIds));
|
||||
}
|
||||
|
||||
// Affinity between two lanes: count of task pairs that share upstream deps
|
||||
function laneAffinity(laneA: number, laneB: number): number {
|
||||
const aTasks = tasksByLane.get(laneA) ?? [];
|
||||
const bTasks = tasksByLane.get(laneB) ?? [];
|
||||
let score = 0;
|
||||
for (const a of aTasks) {
|
||||
const aUp = taskUpstreams.get(a);
|
||||
if (!aUp || aUp.size === 0) continue;
|
||||
for (const b of bTasks) {
|
||||
const bUp = taskUpstreams.get(b);
|
||||
if (!bUp || bUp.size === 0) continue;
|
||||
// Count shared upstreams
|
||||
for (const u of aUp) {
|
||||
if (bUp.has(u)) {
|
||||
score++;
|
||||
break; // one shared upstream is enough for this pair
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
// Simple bubble sort passes (max 3 passes for stability)
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
let swapped = false;
|
||||
for (let i = 0; i < laneIds.length - 1; i++) {
|
||||
const curr = laneIds[i];
|
||||
const next = laneIds[i + 1];
|
||||
|
||||
// Check if swapping improves adjacency with neighbours
|
||||
const prev = i > 0 ? laneIds[i - 1] : -1;
|
||||
const after = i + 2 < laneIds.length ? laneIds[i + 2] : -1;
|
||||
|
||||
let scoreBefore = 0;
|
||||
let scoreAfter = 0;
|
||||
|
||||
if (prev >= 0) {
|
||||
scoreBefore += laneAffinity(prev, curr);
|
||||
scoreAfter += laneAffinity(prev, next);
|
||||
}
|
||||
if (after >= 0) {
|
||||
scoreBefore += laneAffinity(next, after);
|
||||
scoreAfter += laneAffinity(curr, after);
|
||||
}
|
||||
scoreBefore += laneAffinity(curr, next);
|
||||
scoreAfter += laneAffinity(next, curr); // same, symmetric
|
||||
|
||||
if (scoreAfter > scoreBefore) {
|
||||
laneIds[i] = next;
|
||||
laneIds[i + 1] = curr;
|
||||
swapped = true;
|
||||
}
|
||||
}
|
||||
if (!swapped) break;
|
||||
}
|
||||
|
||||
// Remap lane assignments to the reordered indices
|
||||
const reorderMap = new Map<number, number>();
|
||||
for (let newIdx = 0; newIdx < laneIds.length; newIdx++) {
|
||||
reorderMap.set(laneIds[newIdx], newIdx);
|
||||
}
|
||||
for (const [taskId, oldLane] of assignment) {
|
||||
assignment.set(taskId, reorderMap.get(oldLane) ?? oldLane);
|
||||
}
|
||||
}
|
||||
|
||||
return assignment;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Milestone lane assignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Position milestones in a lane that centres them vertically relative to
|
||||
* the tasks they connect to. Start and end milestones go to a middle lane.
|
||||
* Internal merge/fork milestones are placed at the median lane of their
|
||||
* connected tasks.
|
||||
*/
|
||||
function assignMilestoneLanes(
|
||||
milestones: TimelineMilestone[],
|
||||
milestoneEdges: TimelineEdge[],
|
||||
taskLanes: Map<string, number>,
|
||||
laneCount: number,
|
||||
): Map<string, number> {
|
||||
const assignment = new Map<string, number>();
|
||||
const midLane = Math.max(0, Math.floor((laneCount - 1) / 2));
|
||||
|
||||
for (const ms of milestones) {
|
||||
if (ms.kind === "start" || ms.kind === "end") {
|
||||
assignment.set(ms.id, midLane);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gather lanes of connected tasks
|
||||
const connectedLanes: number[] = [];
|
||||
for (const e of milestoneEdges) {
|
||||
if (e.from === ms.id) {
|
||||
const lane = taskLanes.get(e.to);
|
||||
if (lane != null) connectedLanes.push(lane);
|
||||
}
|
||||
if (e.to === ms.id) {
|
||||
const lane = taskLanes.get(e.from);
|
||||
if (lane != null) connectedLanes.push(lane);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedLanes.length > 0) {
|
||||
connectedLanes.sort((a, b) => a - b);
|
||||
const median = connectedLanes[Math.floor(connectedLanes.length / 2)];
|
||||
assignment.set(ms.id, median);
|
||||
} else {
|
||||
assignment.set(ms.id, midLane);
|
||||
}
|
||||
}
|
||||
|
||||
return assignment;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build TimelineNode array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildNodes(
|
||||
tasks: TimelineTask[],
|
||||
milestones: TimelineMilestone[],
|
||||
taskLanes: Map<string, number>,
|
||||
milestoneLanes: Map<string, number>,
|
||||
scale: TimeScale,
|
||||
config: LayoutConfig,
|
||||
): TimelineNode[] {
|
||||
const nodes: TimelineNode[] = [];
|
||||
|
||||
// Task nodes
|
||||
for (const task of tasks) {
|
||||
const lane = taskLanes.get(task.id) ?? 0;
|
||||
const left =
|
||||
task.startMs != null
|
||||
? timeToPx(task.startMs, scale, config)
|
||||
: timeToPx(
|
||||
scale.maxMs - (scale.maxMs - scale.minMs) * 0.05,
|
||||
scale,
|
||||
config,
|
||||
);
|
||||
let right =
|
||||
task.endMs != null
|
||||
? timeToPx(task.endMs, scale, config)
|
||||
: left + config.minBarWidth;
|
||||
|
||||
if (right - left < config.minBarWidth) {
|
||||
right = left + config.minBarWidth;
|
||||
}
|
||||
|
||||
const y =
|
||||
config.paddingTop +
|
||||
lane * config.laneHeight +
|
||||
(config.laneHeight - config.barHeight) / 2;
|
||||
|
||||
nodes.push({
|
||||
type: "task",
|
||||
id: task.id,
|
||||
lane,
|
||||
x: left,
|
||||
y,
|
||||
width: right - left,
|
||||
task,
|
||||
});
|
||||
}
|
||||
|
||||
// Milestone nodes
|
||||
for (const ms of milestones) {
|
||||
const lane = milestoneLanes.get(ms.id) ?? 0;
|
||||
const x = timeToPx(ms.timeMs, scale, config);
|
||||
const y =
|
||||
config.paddingTop + lane * config.laneHeight + config.laneHeight / 2;
|
||||
|
||||
nodes.push({
|
||||
type: "milestone",
|
||||
id: ms.id,
|
||||
lane,
|
||||
x,
|
||||
y,
|
||||
width: config.milestoneSize,
|
||||
milestone: ms,
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid line computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GridLine {
|
||||
/** X pixel position */
|
||||
x: number;
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
/** Whether this is a major gridline (gets a label) */
|
||||
major: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute vertical gridlines at "nice" time intervals.
|
||||
*
|
||||
* Picks an interval that gives roughly 6–12 major gridlines across
|
||||
* the visible chart width.
|
||||
*/
|
||||
export function computeGridLines(
|
||||
scale: TimeScale,
|
||||
config: LayoutConfig,
|
||||
): GridLine[] {
|
||||
const rangeMs = scale.maxMs - scale.minMs;
|
||||
if (rangeMs <= 0) return [];
|
||||
|
||||
// Target ~8 major gridlines
|
||||
const targetCount = 8;
|
||||
const rawInterval = rangeMs / targetCount;
|
||||
|
||||
// Snap to a "nice" interval
|
||||
const niceIntervals = [
|
||||
100,
|
||||
200,
|
||||
500, // sub-second
|
||||
1000,
|
||||
2000,
|
||||
5000, // seconds
|
||||
10_000,
|
||||
15_000,
|
||||
30_000, // tens of seconds
|
||||
60_000,
|
||||
120_000,
|
||||
300_000, // minutes
|
||||
600_000,
|
||||
900_000,
|
||||
1_800_000, // tens of minutes
|
||||
3_600_000,
|
||||
7_200_000, // hours
|
||||
14_400_000,
|
||||
28_800_000,
|
||||
43_200_000, // multi-hour
|
||||
86_400_000, // day
|
||||
];
|
||||
|
||||
let interval = niceIntervals[0];
|
||||
for (const ni of niceIntervals) {
|
||||
interval = ni;
|
||||
if (ni >= rawInterval) break;
|
||||
}
|
||||
|
||||
const lines: GridLine[] = [];
|
||||
|
||||
// Start at the first "nice" multiple >= minMs
|
||||
const firstTick = Math.ceil(scale.minMs / interval) * interval;
|
||||
|
||||
for (let ms = firstTick; ms <= scale.maxMs; ms += interval) {
|
||||
const x = timeToPx(ms, scale, config);
|
||||
lines.push({
|
||||
x,
|
||||
label: formatTimeLabel(ms, interval),
|
||||
major: true,
|
||||
});
|
||||
|
||||
// Add a minor gridline halfway if the interval is large enough
|
||||
if (interval >= 2000) {
|
||||
const midMs = ms + interval / 2;
|
||||
if (midMs < scale.maxMs) {
|
||||
lines.push({
|
||||
x: timeToPx(midMs, scale, config),
|
||||
label: "",
|
||||
major: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Format a timestamp as a short label relative to the chart start */
|
||||
function formatTimeLabel(ms: number, intervalMs: number): string {
|
||||
const date = new Date(ms);
|
||||
|
||||
if (intervalMs >= 86_400_000) {
|
||||
// Days — show date
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
if (intervalMs >= 3_600_000) {
|
||||
// Hours — show HH:MM
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
if (intervalMs >= 60_000) {
|
||||
// Minutes — show HH:MM:SS
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
if (intervalMs >= 1000) {
|
||||
// Seconds — show HH:MM:SS
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// Sub-second — show with milliseconds
|
||||
return (
|
||||
date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) +
|
||||
"." +
|
||||
String(date.getMilliseconds()).padStart(3, "0")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: computeLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function computeLayout(
|
||||
tasks: TimelineTask[],
|
||||
taskEdges: TimelineEdge[],
|
||||
milestones: TimelineMilestone[],
|
||||
milestoneEdges: TimelineEdge[],
|
||||
/** Desired chart width (pixels). The layout will use this for the x-scale. */
|
||||
chartWidth: number,
|
||||
configOverrides?: Partial<LayoutConfig>,
|
||||
/** Direct task→task edge keys that are replaced by milestone-routed paths.
|
||||
* These are filtered out of `taskEdges` to avoid duplicate rendering. */
|
||||
suppressedEdgeKeys?: Set<string>,
|
||||
): ComputedLayout {
|
||||
const config: LayoutConfig = { ...DEFAULT_LAYOUT, ...configOverrides };
|
||||
|
||||
// Use a reasonable minimum width
|
||||
const effectiveWidth = Math.max(chartWidth, 400);
|
||||
|
||||
// 1. Build time scale
|
||||
const scale = buildTimeScale(tasks, milestones, effectiveWidth, config);
|
||||
|
||||
// 2. Assign task lanes
|
||||
const taskLanes = assignLanes(tasks, scale, config);
|
||||
|
||||
// Count lanes
|
||||
let laneCount = 0;
|
||||
for (const lane of taskLanes.values()) {
|
||||
laneCount = Math.max(laneCount, lane + 1);
|
||||
}
|
||||
// Ensure at least 1 lane even if there are no tasks
|
||||
laneCount = Math.max(laneCount, 1);
|
||||
|
||||
// 3. Assign milestone lanes
|
||||
const milestoneLanes = assignMilestoneLanes(
|
||||
milestones,
|
||||
milestoneEdges,
|
||||
taskLanes,
|
||||
laneCount,
|
||||
);
|
||||
|
||||
// 4. Build node positions
|
||||
const nodes = buildNodes(
|
||||
tasks,
|
||||
milestones,
|
||||
taskLanes,
|
||||
milestoneLanes,
|
||||
scale,
|
||||
config,
|
||||
);
|
||||
|
||||
// 5. Merge all edges, filtering out any task edges that have been
|
||||
// replaced by milestone-routed paths (e.g. A→C replaced by A→merge→C).
|
||||
const filteredTaskEdges = suppressedEdgeKeys?.size
|
||||
? taskEdges.filter((e) => !suppressedEdgeKeys.has(`${e.from}→${e.to}`))
|
||||
: taskEdges;
|
||||
const allEdges = [...filteredTaskEdges, ...milestoneEdges];
|
||||
|
||||
// Deduplicate edges (same from→to)
|
||||
const edgeSet = new Set<string>();
|
||||
const dedupedEdges: TimelineEdge[] = [];
|
||||
for (const e of allEdges) {
|
||||
const key = `${e.from}→${e.to}`;
|
||||
if (!edgeSet.has(key)) {
|
||||
edgeSet.add(key);
|
||||
dedupedEdges.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Compute total dimensions
|
||||
const totalWidth = effectiveWidth;
|
||||
const totalHeight =
|
||||
config.paddingTop + laneCount * config.laneHeight + config.paddingBottom;
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges: dedupedEdges,
|
||||
totalWidth,
|
||||
totalHeight,
|
||||
laneCount,
|
||||
minTimeMs: scale.minMs,
|
||||
maxTimeMs: scale.maxMs,
|
||||
pxPerMs: scale.pxPerMs,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bezier edge path generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate an SVG cubic Bezier path string for an edge between two nodes.
|
||||
*
|
||||
* Edges flow left→right. The control points bend horizontally so curves
|
||||
* are smooth and mostly follow the x-axis direction.
|
||||
*
|
||||
* Anchoring:
|
||||
* - Task nodes: outgoing from right-center, incoming at left-center
|
||||
* - Milestones: connect at center
|
||||
*/
|
||||
export function computeEdgePath(
|
||||
fromNode: TimelineNode,
|
||||
toNode: TimelineNode,
|
||||
config: LayoutConfig = DEFAULT_LAYOUT,
|
||||
): string {
|
||||
let x1: number, y1: number, x2: number, y2: number;
|
||||
|
||||
// Source anchor
|
||||
if (fromNode.type === "task") {
|
||||
x1 = fromNode.x + fromNode.width; // right edge
|
||||
y1 = fromNode.y + config.barHeight / 2; // vertical center
|
||||
} else {
|
||||
x1 = fromNode.x;
|
||||
y1 = fromNode.y;
|
||||
}
|
||||
|
||||
// Target anchor
|
||||
if (toNode.type === "task") {
|
||||
x2 = toNode.x; // left edge
|
||||
y2 = toNode.y + config.barHeight / 2; // vertical center
|
||||
} else {
|
||||
x2 = toNode.x;
|
||||
y2 = toNode.y;
|
||||
}
|
||||
|
||||
// Handle edge case where target is to the left of source (e.g., timing quirks)
|
||||
// In that case, draw a slight arc that loops
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
|
||||
if (dx < 5) {
|
||||
// Target is to the left or very close — use an S-curve that goes
|
||||
// slightly below/above and loops back
|
||||
const loopOffset = Math.max(30, Math.abs(dx) + 20);
|
||||
const yMid = (y1 + y2) / 2 + (dy >= 0 ? 20 : -20);
|
||||
|
||||
return [
|
||||
`M ${x1} ${y1}`,
|
||||
`C ${x1 + loopOffset} ${y1}, ${x2 - loopOffset} ${yMid}, ${(x1 + x2) / 2} ${yMid}`,
|
||||
`C ${(x1 + x2) / 2 + loopOffset} ${yMid}, ${x2 - loopOffset} ${y2}, ${x2} ${y2}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// Normal left→right Bezier
|
||||
// Control point offset: 40% of horizontal distance, clamped
|
||||
const cpOffset = Math.min(Math.max(dx * 0.4, 20), 120);
|
||||
|
||||
const cx1 = x1 + cpOffset;
|
||||
const cy1 = y1;
|
||||
const cx2 = x2 - cpOffset;
|
||||
const cy2 = y2;
|
||||
|
||||
return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export timeToPx for use by the renderer (gridlines etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { timeToPx, type TimeScale };
|
||||
285
web/src/components/executions/workflow-timeline/types.ts
Normal file
285
web/src/components/executions/workflow-timeline/types.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Workflow Timeline DAG Types
|
||||
*
|
||||
* Types for the Prefect-style workflow run timeline visualization.
|
||||
* This component renders workflow task executions as horizontal duration bars
|
||||
* on a time axis with curved dependency edges showing the DAG structure.
|
||||
*/
|
||||
|
||||
import type { ExecutionSummary } from "@/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TaskState =
|
||||
| "completed"
|
||||
| "running"
|
||||
| "failed"
|
||||
| "pending"
|
||||
| "timeout"
|
||||
| "cancelled"
|
||||
| "abandoned";
|
||||
|
||||
/**
|
||||
* Metadata for a collapsed with_items group node.
|
||||
* When a with_items task has ≥ WITH_ITEMS_COLLAPSE_THRESHOLD items, all
|
||||
* individual item executions are merged into a single TimelineTask carrying
|
||||
* this info so the renderer can display a compact "task ×N" bar.
|
||||
*/
|
||||
export interface WithItemsGroupInfo {
|
||||
/** Total number of items in the group */
|
||||
totalItems: number;
|
||||
/** Per-state item counts */
|
||||
completed: number;
|
||||
failed: number;
|
||||
running: number;
|
||||
pending: number;
|
||||
timedOut: number;
|
||||
cancelled: number;
|
||||
/** Concurrency limit declared on the task (0 = unlimited / unknown) */
|
||||
concurrency: number;
|
||||
/** IDs of all member executions (for upstream/downstream tracking) */
|
||||
memberIds: string[];
|
||||
}
|
||||
|
||||
/** Threshold at which with_items children are collapsed into a single node */
|
||||
export const WITH_ITEMS_COLLAPSE_THRESHOLD = 10;
|
||||
|
||||
/** A single task run positioned on the timeline */
|
||||
export interface TimelineTask {
|
||||
/** Unique identifier (execution ID as string) */
|
||||
id: string;
|
||||
/** Display name (task_name from workflow_task metadata) */
|
||||
name: string;
|
||||
/** Action reference */
|
||||
actionRef: string;
|
||||
/** Visual state for coloring */
|
||||
state: TaskState;
|
||||
/** Start time as epoch ms (null if not yet started) */
|
||||
startMs: number | null;
|
||||
/** End time as epoch ms (null if still running or not started) */
|
||||
endMs: number | null;
|
||||
/** IDs of upstream tasks this depends on */
|
||||
upstreamIds: string[];
|
||||
/** IDs of downstream tasks that depend on this */
|
||||
downstreamIds: string[];
|
||||
/** with_items task index (null if not a with_items expansion) */
|
||||
taskIndex: number | null;
|
||||
/** Whether this task timed out */
|
||||
timedOut: boolean;
|
||||
/** Retry info */
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
/** Duration in ms (from metadata or computed) */
|
||||
durationMs: number | null;
|
||||
/** Original execution summary for tooltip details */
|
||||
execution: ExecutionSummary;
|
||||
/**
|
||||
* Present only on collapsed with_items group nodes.
|
||||
* When set, this task represents multiple item executions merged into one.
|
||||
*/
|
||||
groupInfo?: WithItemsGroupInfo;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic milestone / junction nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MilestoneKind = "start" | "end" | "merge" | "fork";
|
||||
|
||||
export interface TimelineMilestone {
|
||||
id: string;
|
||||
kind: MilestoneKind;
|
||||
/** Position on the time axis (epoch ms) */
|
||||
timeMs: number;
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified node type (task bar OR milestone)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineNodeType = "task" | "milestone";
|
||||
|
||||
export interface TimelineNode {
|
||||
type: TimelineNodeType;
|
||||
/** Unique ID */
|
||||
id: string;
|
||||
/** Assigned lane (y index) */
|
||||
lane: number;
|
||||
/** Pixel positions (computed by layout) */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
/** Original data */
|
||||
task?: TimelineTask;
|
||||
milestone?: TimelineMilestone;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edges
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EdgeKind = "success" | "failure" | "always" | "timeout" | "custom";
|
||||
|
||||
export interface TimelineEdge {
|
||||
/** Source node ID */
|
||||
from: string;
|
||||
/** Target node ID */
|
||||
to: string;
|
||||
/** Visual classification for coloring */
|
||||
kind: EdgeKind;
|
||||
/** Optional transition label (e.g. "succeeded", "failed") */
|
||||
label?: string;
|
||||
/** Optional custom color from workflow definition */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LayoutConfig {
|
||||
/** Height of each lane in pixels */
|
||||
laneHeight: number;
|
||||
/** Height of a task bar in pixels */
|
||||
barHeight: number;
|
||||
/** Vertical padding within each lane */
|
||||
lanePadding: number;
|
||||
/** Size of milestone diamond/square in pixels */
|
||||
milestoneSize: number;
|
||||
/** Left padding for the chart area (px) */
|
||||
paddingLeft: number;
|
||||
/** Right padding for the chart area (px) */
|
||||
paddingRight: number;
|
||||
/** Top padding for the time axis area (px) */
|
||||
paddingTop: number;
|
||||
/** Bottom padding (px) */
|
||||
paddingBottom: number;
|
||||
/** Minimum bar width for very short tasks (px) */
|
||||
minBarWidth: number;
|
||||
/** Horizontal gap between milestone and adjacent bars (px) */
|
||||
milestoneGap: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LAYOUT: LayoutConfig = {
|
||||
laneHeight: 32,
|
||||
barHeight: 20,
|
||||
lanePadding: 6,
|
||||
milestoneSize: 10,
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
paddingTop: 36,
|
||||
paddingBottom: 16,
|
||||
minBarWidth: 8,
|
||||
milestoneGap: 12,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed layout result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ComputedLayout {
|
||||
nodes: TimelineNode[];
|
||||
edges: TimelineEdge[];
|
||||
/** Total width needed (px) */
|
||||
totalWidth: number;
|
||||
/** Total height needed (px) */
|
||||
totalHeight: number;
|
||||
/** Number of lanes used */
|
||||
laneCount: number;
|
||||
/** Time bounds */
|
||||
minTimeMs: number;
|
||||
maxTimeMs: number;
|
||||
/** The linear scale factor: px per ms */
|
||||
pxPerMs: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interaction state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TooltipData {
|
||||
task: TimelineTask;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ViewState {
|
||||
/** Horizontal scroll offset (px) */
|
||||
scrollX: number;
|
||||
/** Zoom level (1.0 = default) */
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow definition transition types (for edge extraction)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorkflowDefinitionTransition {
|
||||
when?: string;
|
||||
publish?: Record<string, string>[];
|
||||
do?: string[];
|
||||
__chart_meta__?: {
|
||||
label?: string;
|
||||
color?: string;
|
||||
line_style?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowDefinitionTask {
|
||||
name: string;
|
||||
action?: string;
|
||||
next?: WorkflowDefinitionTransition[];
|
||||
/** Number of inbound tasks that must complete before this task runs */
|
||||
join?: number;
|
||||
/** with_items expression (present when the task fans out over a list) */
|
||||
with_items?: string;
|
||||
/** Max concurrent items for with_items (default 1 = serial) */
|
||||
concurrency?: number;
|
||||
// Legacy fields (auto-converted to next)
|
||||
on_success?: string | string[];
|
||||
on_failure?: string | string[];
|
||||
on_complete?: string | string[];
|
||||
on_timeout?: string | string[];
|
||||
}
|
||||
|
||||
export interface WorkflowDefinition {
|
||||
ref?: string;
|
||||
label?: string;
|
||||
tasks?: WorkflowDefinitionTask[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STATE_COLORS: Record<
|
||||
TaskState,
|
||||
{ bg: string; border: string; text: string }
|
||||
> = {
|
||||
completed: { bg: "#dcfce7", border: "#22c55e", text: "#15803d" },
|
||||
running: { bg: "#dbeafe", border: "#3b82f6", text: "#1d4ed8" },
|
||||
failed: { bg: "#fee2e2", border: "#ef4444", text: "#b91c1c" },
|
||||
pending: { bg: "#f3f4f6", border: "#9ca3af", text: "#6b7280" },
|
||||
timeout: { bg: "#ffedd5", border: "#f97316", text: "#c2410c" },
|
||||
cancelled: { bg: "#f3f4f6", border: "#9ca3af", text: "#6b7280" },
|
||||
abandoned: { bg: "#fee2e2", border: "#f87171", text: "#b91c1c" },
|
||||
};
|
||||
|
||||
export const EDGE_KIND_COLORS: Record<EdgeKind, string> = {
|
||||
success: "#22c55e",
|
||||
failure: "#ef4444",
|
||||
always: "#9ca3af",
|
||||
timeout: "#f97316",
|
||||
custom: "#8b5cf6",
|
||||
};
|
||||
|
||||
export const MILESTONE_COLORS: Record<MilestoneKind, string> = {
|
||||
start: "#6b7280",
|
||||
end: "#6b7280",
|
||||
merge: "#8b5cf6",
|
||||
fork: "#8b5cf6",
|
||||
};
|
||||
Reference in New Issue
Block a user