diff --git a/src/pages/usage/TokenLedger.tsx b/src/pages/usage/TokenLedger.tsx index 46ec756..e4d20c3 100644 --- a/src/pages/usage/TokenLedger.tsx +++ b/src/pages/usage/TokenLedger.tsx @@ -9,6 +9,7 @@ import { import { usageApi, agentsApi } from '@/api/client' import type { ApiTokenUsageItem } from '@/api/client' import { StatCard, LoadingState, ErrorState } from '@/components/agos-ux' +import { useMe } from '@/hooks/useMe' import { UsageCharts } from './UsageCharts' // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -52,10 +53,10 @@ function truncId(id: string | null) { // ── Expandable Row Detail ───────────────────────────────────────────────────── -function RowDetail({ item }: { item: ApiTokenUsageItem }) { +function RowDetail({ item, isAdmin }: { item: ApiTokenUsageItem; isAdmin: boolean }) { return ( - +
}) { +type LedgerMode = 'llm' | 'deductions' + +function LedgerTable({ items, agentNames, isAdmin }: { items: ApiTokenUsageItem[]; agentNames: Record; isAdmin: boolean }) { const [expandedId, setExpandedId] = useState(null) if (items.length === 0) { @@ -111,7 +114,7 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN - {['', 'Model', 'Stream', 'Prompt', 'Completion', 'Cache', 'Total', 'Cost', 'Agent', 'Session', 'When'].map(h => ( + {['', 'Model', ...(isAdmin ? ['Stream'] : []), 'Prompt', 'Completion', 'Cache', 'Total', 'Cost', 'Agent', 'Session', 'When'].map(h => ( + {isAdmin && ( + + )} - {expanded && } + {expanded && } ) })} @@ -204,8 +209,8 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN // ── Section Card ────────────────────────────────────────────────────────────── -function SectionCard({ title, icon: Icon, children }: { - title: string; icon: React.ElementType; children: React.ReactNode +function SectionCard({ title, icon: Icon, children, action }: { + title: string; icon: React.ElementType; children: React.ReactNode; action?: React.ReactNode }) { return (
{title} + {action}
{children}
) } +// Pill toggle — two options, one selected at a time +function PillToggle({ value, options, onChange }: { + value: T + options: { value: T; label: string }[] + onChange: (v: T) => void +}) { + return ( +
+ {options.map(opt => ( + + ))} +
+ ) +} + // ── Chart Panel Width ───────────────────────────────────────────────────────── const CHART_PANEL_WIDTH = 500 @@ -236,6 +274,8 @@ const CHART_PANEL_WIDTH = 500 export function TokenLedger() { const [page, setPage] = useState(1) const [panelOpen, setPanelOpen] = useState(true) + const [ledgerMode, setLedgerMode] = useState('llm') + const { isAdmin } = useMe() const { data: ledgerData, isLoading, error, refetch } = useQuery({ queryKey: ['token-ledger', page], @@ -243,7 +283,6 @@ export function TokenLedger() { staleTime: 15_000, }) - const items = ledgerData?.items ?? [] const total = ledgerData?.total ?? 0 // Aggregate data for KPIs + charts panel @@ -299,6 +338,18 @@ export function TokenLedger() { return sorted[0].group_key || '—' }, [modelItems]) + // Filter ledger rows by mode: + // 'llm' — real model calls (has model OR has prompt/completion tokens) + // 'deductions'— quota accounting rows (stream=platform, no model, 0 tokens) + const items = useMemo(() => { + const all = ledgerData?.items ?? [] + if (ledgerMode === 'llm') { + return all.filter(i => i.model || i.prompt_tokens > 0 || i.completion_tokens > 0) + } + // deductions: stream=platform with no model and 0 actual tokens + return all.filter(i => i.stream === 'platform' && !i.model && i.prompt_tokens === 0 && i.completion_tokens === 0) + }, [ledgerData, ledgerMode]) + return (
} {!isLoading && !error && ( - - + + value={ledgerMode} + onChange={setLedgerMode} + options={[ + { value: 'llm', label: 'LLM Calls' }, + { value: 'deductions', label: 'Quota Deductions' }, + ]} + /> + }> + {total > 50 && (
)} diff --git a/src/pages/usage/UsageCharts.tsx b/src/pages/usage/UsageCharts.tsx index ca3dc7c..2a41c67 100644 --- a/src/pages/usage/UsageCharts.tsx +++ b/src/pages/usage/UsageCharts.tsx @@ -62,11 +62,14 @@ function fmtUsd(n: number) { return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } -const GROUP_OPTIONS = [ +const GROUP_OPTIONS_BASE = [ { value: 'model', label: 'Model' }, { value: 'agent_id', label: 'Agent' }, { value: 'app_id', label: 'App' }, { value: 'day', label: 'Day' }, +] +const GROUP_OPTIONS_ADMIN = [ + ...GROUP_OPTIONS_BASE, { value: 'stream', label: 'Stream' }, ] @@ -311,6 +314,27 @@ function buildHeatmapOptions(dayData: ApiTokenUsageAggregateItem[]): Highcharts. }) as Highcharts.Options } +// ── Dist pill toggle ───────────────────────────────────────────────────────── + +function DistPillToggle({ value, onChange }: { value: 'model' | 'stream'; onChange: (v: 'model' | 'stream') => void }) { + return ( +
+ {(['model', 'stream'] as const).map(v => ( + + ))} +
+ ) +} + // ── Collapsible Section ────────────────────────────────────────────────────── function Section({ title, icon: Icon, open, onToggle, action, children }: { @@ -365,13 +389,15 @@ interface UsageChartsProps { agentData: ApiTokenUsageAggregateItem[] dayData: ApiTokenUsageAggregateItem[] streamData: ApiTokenUsageAggregateItem[] + isAdmin: boolean } -export function UsageCharts({ modelData, agentData, dayData, streamData }: UsageChartsProps) { +export function UsageCharts({ modelData, agentData, dayData, streamData, isAdmin }: UsageChartsProps) { const [openSections, setOpenSections] = useState>({ costs: true, distribution: true, models: true, activity: true, }) const [groupBy, setGroupBy] = useState('model') + const [distView, setDistView] = useState<'model' | 'stream'>('model') const chartRefModelDonut = useRef(null) const chartRefStreamDonut = useRef(null) const chartRefBar = useRef(null) @@ -419,7 +445,7 @@ export function UsageCharts({ modelData, agentData, dayData, streamData }: Usage action={
- +
} > @@ -435,11 +461,16 @@ export function UsageCharts({ modelData, agentData, dayData, streamData }: Usage icon={PieChart} open={openSections.distribution} onToggle={() => toggle('distribution')} + action={isAdmin ? ( + + ) : undefined} > - By Model - - By Stream - + {(!isAdmin || distView === 'model') && ( + + )} + {isAdmin && distView === 'stream' && ( + + )} {/* Models */} diff --git a/src/pages/workflows/components/DAGBuilder.tsx b/src/pages/workflows/components/DAGBuilder.tsx index e63da67..5c10d49 100644 --- a/src/pages/workflows/components/DAGBuilder.tsx +++ b/src/pages/workflows/components/DAGBuilder.tsx @@ -22,6 +22,10 @@ import { coerceNodeToType } from './node-registry' import { WorkflowCanvas } from './WorkflowCanvas' import { NodePalette } from './palette/NodePalette' import { InspectorPanel } from './inspector/InspectorPanel' +import { ExecutionControls } from './execution/ExecutionControls' +import { ExecutionTimeline } from './execution/ExecutionTimeline' +import { NodeExecutionDetail } from './execution/NodeExecutionDetail' +import { EXECUTION_OVERLAY_STYLES } from './execution/ExecutionOverlay' const FLOATING_PANEL_STYLE = { background: 'color-mix(in srgb, var(--nbl-surface) 92%, rgba(0, 0, 0, 0.28))', @@ -73,6 +77,8 @@ export function DAGBuilder({ const rightPanelCollapsed = store(s => s.rightPanelCollapsed) const leftPanelPinned = store(s => s.leftPanelPinned) const leftPanelHovered = store(s => s.leftPanelHovered) + const executionState = store(s => s.executionState) + const executionOverlayVisible = store(s => s.executionOverlayVisible) const selectedNode = selectedNodeId ? definition.nodes.find(n => n.id === selectedNodeId) @@ -168,8 +174,16 @@ export function DAGBuilder({ /* ── Render ─────────────────────────────────────────────────────────── */ + const execNodeStates = executionOverlayVisible && executionState + ? executionState.node_states + : undefined + + const selectedNodeExec = selectedNodeId && executionState?.node_states[selectedNodeId] + return (
+ {/* Inject execution keyframe styles */} + {executionOverlayVisible && }
{/* Left panel: Node Palette */} @@ -199,7 +214,7 @@ export function DAGBuilder({ /> - {/* Right panel: Inspector */} + {/* Right panel: Inspector or Execution Detail */} + {/* Execution detail for selected node */} + {selectedNodeExec && executionOverlayVisible && ( +
+ +
+ )} + {/* Execution timeline */} + {executionOverlayVisible && executionState && ( +
+ +
+ )} + {rightStackFooter} + + } />
+ {/* Execution controls overlay (top-center) */} + {executionOverlayVisible && executionState && ( + + store.getState().clearExecution()} + /> + + )} + {/* Bottom-left hint */}
diff --git a/src/pages/workflows/components/WorkflowCanvas.tsx b/src/pages/workflows/components/WorkflowCanvas.tsx index 31dd26b..8791954 100644 --- a/src/pages/workflows/components/WorkflowCanvas.tsx +++ b/src/pages/workflows/components/WorkflowCanvas.tsx @@ -31,7 +31,7 @@ import { MarkerType, } from '@xyflow/react' import '@xyflow/react/dist/style.css' -import type { WfDefinition, WfNodeType } from '../types' +import type { WfDefinition, WfNodeType, WfNodeExecutionState } from '../types' import { PALETTE_DRAG_FORMAT } from './palette/NodePalette' import { validateConnection, validateGraph } from './graph-validation' import { FLOW_NODE_TYPES } from './flow-node-types' @@ -58,6 +58,8 @@ interface WorkflowCanvasProps { onConnect: (source: string, target: string) => void onRemoveEdge: (edgeId: string) => void onAddNode?: (type: WfNodeType, label: string) => void + /** Per-node execution state (for live execution overlay) */ + execStateByNode?: Record /** Panels rendered inside ReactFlow (required for correct positioning) */ children?: React.ReactNode } @@ -77,6 +79,7 @@ export function WorkflowCanvas({ onConnect, onRemoveEdge, onAddNode, + execStateByNode, children, }: WorkflowCanvasProps) { const [flowInstance, setFlowInstance] = useState(null) @@ -98,8 +101,8 @@ export function WorkflowCanvas({ }, [definition]) const rawNodes = useMemo( - () => toFlowNodes(definition, selectedNodeId, onDeleteNode, validationByNode), - [definition, selectedNodeId, onDeleteNode, validationByNode], + () => toFlowNodes(definition, selectedNodeId, onDeleteNode, validationByNode, execStateByNode), + [definition, selectedNodeId, onDeleteNode, validationByNode, execStateByNode], ) const rawEdges = useMemo( () => toFlowEdges(definition), diff --git a/src/pages/workflows/components/WorkflowNode.tsx b/src/pages/workflows/components/WorkflowNode.tsx index c304ab8..4a57351 100644 --- a/src/pages/workflows/components/WorkflowNode.tsx +++ b/src/pages/workflows/components/WorkflowNode.tsx @@ -2,6 +2,7 @@ import { AlertCircle, AlertTriangle, X, Zap } from 'lucide-react' import { type NodeProps, Handle, Position } from '@xyflow/react' import { NODE_ICON, NODE_W, NODE_H, NODE_TYPE_LABEL, NODE_SHAPE_BY_FAMILY, getNodeFamilyColor, type WfNodeData } from './workflow-node-constants' import { getNodeFamily } from '../types' +import { NodeExecutionBadge, getNodeExecBorderColor, isNodeExecPulsing } from './execution/ExecutionOverlay' const HANDLE_STYLE = { width: 16, @@ -29,26 +30,35 @@ export function WorkflowNode({ id, data }: NodeProps) { ? 'polygon(50% 0%, 96% 50%, 50% 100%, 4% 50%)' : undefined + const execBorderColor = getNodeExecBorderColor(d.execState) + const execPulsing = isNodeExecPulsing(d.execState) + const borderColor = execBorderColor ?? (d.selected ? color : `color-mix(in srgb, ${color} 24%, var(--nbl-border))`) + return (
{d.type !== 'start' && } @@ -97,7 +107,8 @@ export function WorkflowNode({ id, data }: NodeProps) { )} {d.type !== 'end' && } - {d.validationBadge && ( + {d.execState && } + {d.validationBadge && !d.execState && (
void, validationByNode?: Map, + execStateByNode?: Record, ): Node[] { return def.nodes.map((node) => ({ id: node.id, @@ -39,6 +40,7 @@ export function toFlowNodes( selected: node.id === selectedId, onDelete, validationBadge: validationByNode?.get(node.id), + execState: execStateByNode?.[node.id], } as WfNodeData, })) } diff --git a/src/pages/workflows/components/execution/ExecutionControls.tsx b/src/pages/workflows/components/execution/ExecutionControls.tsx new file mode 100644 index 0000000..8ab1dbb --- /dev/null +++ b/src/pages/workflows/components/execution/ExecutionControls.tsx @@ -0,0 +1,104 @@ +/** + * ExecutionControls — toolbar for execution actions (shown during active execution). + * + * Actions: view timeline, cancel, close overlay. + */ + +import { X, Clock, DollarSign, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react' +import type { WfExecutionState } from '../../types' + +interface ExecutionControlsProps { + executionState: WfExecutionState + onClose: () => void +} + +export function ExecutionControls({ + executionState, + onClose, +}: ExecutionControlsProps) { + const isRunning = executionState.status === 'running' + const isDone = executionState.status === 'completed' + const isFailed = executionState.status === 'failed' || executionState.status === 'cancelled' + + return ( +
+ {/* Status indicator */} + {isRunning && ( + + + Running + + )} + {isDone && ( + + + Completed + + )} + {isFailed && ( + + + {executionState.status === 'cancelled' ? 'Cancelled' : 'Failed'} + + )} + + {/* Separator */} + + + {/* Progress */} + + {executionState.completed_nodes}/{executionState.total_nodes} + + + {/* Duration */} + {executionState.duration_ms > 0 && ( + + + {(executionState.duration_ms / 1000).toFixed(1)}s + + )} + + {/* Cost */} + {executionState.total_cost_usd > 0 && ( + + + ${executionState.total_cost_usd.toFixed(4)} + + )} + + {/* Close */} + +
+ ) +} diff --git a/src/pages/workflows/components/execution/ExecutionOverlay.tsx b/src/pages/workflows/components/execution/ExecutionOverlay.tsx new file mode 100644 index 0000000..bdca930 --- /dev/null +++ b/src/pages/workflows/components/execution/ExecutionOverlay.tsx @@ -0,0 +1,134 @@ +/** + * ExecutionOverlay — renders execution status on top of WorkflowNode. + * + * Shown as colored badges/indicators on each node: + * - pending: dim gray + * - queued: light gray, pulsing + * - running: blue, pulsing border + * - completed: green + * - failed: red + * - skipped: gray, dashed + * - waiting: amber, pulsing + */ + +import type { WfNodeExecutionState, WfNodeExecutionStatus } from '../../types' + +const STATUS_COLORS: Record = { + pending: { bg: 'transparent', border: 'var(--nbl-border)', text: 'var(--nbl-text-ghost)' }, + queued: { bg: 'transparent', border: 'var(--nbl-text-ghost)', text: 'var(--nbl-text-ghost)' }, + running: { bg: 'color-mix(in srgb, var(--nbl-cyan) 12%, transparent)', border: 'var(--nbl-cyan)', text: 'var(--nbl-cyan)' }, + completed: { bg: 'color-mix(in srgb, var(--nbl-green) 12%, transparent)', border: 'var(--nbl-green)', text: 'var(--nbl-green)' }, + failed: { bg: 'color-mix(in srgb, var(--nbl-red) 12%, transparent)', border: 'var(--nbl-red)', text: 'var(--nbl-red)' }, + skipped: { bg: 'transparent', border: 'var(--nbl-text-ghost)', text: 'var(--nbl-text-ghost)' }, + waiting: { bg: 'color-mix(in srgb, var(--nbl-amber) 12%, transparent)', border: 'var(--nbl-amber)', text: 'var(--nbl-amber)' }, +} + +const STATUS_LABELS: Record = { + pending: 'Pending', + queued: 'Queued', + running: 'Running', + completed: 'Done', + failed: 'Failed', + skipped: 'Skipped', + waiting: 'Waiting', +} + +interface NodeExecutionBadgeProps { + nodeExecState: WfNodeExecutionState | undefined +} + +export function NodeExecutionBadge({ nodeExecState }: NodeExecutionBadgeProps) { + if (!nodeExecState) return null + + const { status, duration_ms, cost_usd } = nodeExecState + const colors = STATUS_COLORS[status] + const isPulsing = status === 'running' || status === 'queued' || status === 'waiting' + + return ( +
+ {isPulsing && ( + + )} + {STATUS_LABELS[status]} + {duration_ms != null && status === 'completed' && ( + + {duration_ms < 1000 ? `${duration_ms}ms` : `${(duration_ms / 1000).toFixed(1)}s`} + + )} + {cost_usd != null && cost_usd > 0 && status === 'completed' && ( + + ${cost_usd.toFixed(4)} + + )} +
+ ) +} + +/** + * Returns the CSS border color for a node based on its execution status. + * Used by WorkflowNode to change its border during execution. + */ +export function getNodeExecBorderColor( + nodeExecState: WfNodeExecutionState | undefined, +): string | undefined { + if (!nodeExecState) return undefined + const { status } = nodeExecState + if (status === 'pending') return undefined // use default + return STATUS_COLORS[status].border +} + +/** + * Returns whether a node should show pulsing animation. + */ +export function isNodeExecPulsing( + nodeExecState: WfNodeExecutionState | undefined, +): boolean { + if (!nodeExecState) return false + return nodeExecState.status === 'running' || nodeExecState.status === 'waiting' +} + +/** + * Execution overlay CSS keyframes — inject once. + */ +export const EXECUTION_OVERLAY_STYLES = ` +@keyframes execPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} +@keyframes execDot { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.4); } +} +@keyframes execBorderPulse { + 0%, 100% { box-shadow: 0 0 0 0 transparent; } + 50% { box-shadow: 0 0 8px 2px var(--exec-glow-color, var(--nbl-cyan)); } +} +` diff --git a/src/pages/workflows/components/execution/ExecutionTimeline.tsx b/src/pages/workflows/components/execution/ExecutionTimeline.tsx new file mode 100644 index 0000000..5258c11 --- /dev/null +++ b/src/pages/workflows/components/execution/ExecutionTimeline.tsx @@ -0,0 +1,212 @@ +/** + * ExecutionTimeline — chronological step log showing execution progress. + * + * Each step shows: status icon, node label, duration, cost. + * Expandable to show input/output. + */ + +import { useState } from 'react' +import { + CheckCircle2, Loader2, AlertTriangle, Ban, Clock, + ChevronDown, ChevronRight, DollarSign, +} from 'lucide-react' +import type { WfExecutionState, WfNodeExecutionState, WfNode } from '../../types' + +const STATUS_CONFIGS = { + pending: { icon: null, color: 'var(--nbl-text-ghost)', label: 'Pending' }, + queued: { icon: Loader2, color: 'var(--nbl-text-ghost)', label: 'Queued' }, + running: { icon: Loader2, color: 'var(--nbl-cyan)', label: 'Running' }, + completed: { icon: CheckCircle2, color: 'var(--nbl-green)', label: 'Done' }, + failed: { icon: AlertTriangle, color: 'var(--nbl-red)', label: 'Failed' }, + skipped: { icon: Ban, color: 'var(--nbl-text-ghost)', label: 'Skipped' }, + waiting: { icon: Clock, color: 'var(--nbl-amber)', label: 'Waiting' }, +} as const + +interface ExecutionTimelineProps { + executionState: WfExecutionState + nodes: WfNode[] + onSelectNode?: (nodeId: string) => void +} + +export function ExecutionTimeline({ + executionState, + nodes, + onSelectNode, +}: ExecutionTimelineProps) { + // Order nodes in execution order: completed first, running, then pending + const orderedNodes = [...nodes].sort((a, b) => { + const sa = executionState.node_states[a.id] + const sb = executionState.node_states[b.id] + const order = { completed: 0, failed: 1, running: 2, waiting: 3, queued: 4, pending: 5, skipped: 6 } + return (order[sa?.status ?? 'pending'] ?? 5) - (order[sb?.status ?? 'pending'] ?? 5) + }) + + return ( +
+ {/* Summary */} +
+ + Timeline + + + {executionState.completed_nodes}/{executionState.total_nodes} steps + {executionState.total_cost_usd > 0 && ` · $${executionState.total_cost_usd.toFixed(4)}`} + {executionState.duration_ms > 0 && ` · ${(executionState.duration_ms / 1000).toFixed(1)}s`} + +
+ + {/* Steps */} + {orderedNodes.map(node => { + const nodeExec = executionState.node_states[node.id] + if (!nodeExec) return null + return ( + onSelectNode?.(node.id)} + /> + ) + })} +
+ ) +} + +function TimelineStep({ + node, + nodeExec, + onSelect, +}: { + node: WfNode + nodeExec: WfNodeExecutionState + onSelect: () => void +}) { + const [expanded, setExpanded] = useState(false) + const cfg = STATUS_CONFIGS[nodeExec.status] + const Icon = cfg.icon + const isAnimated = nodeExec.status === 'running' || nodeExec.status === 'queued' + + return ( +
+ + + {/* Expanded detail */} + {expanded && ( +
+ {nodeExec.error && ( +
+ {nodeExec.error} +
+ )} + {nodeExec.output && nodeExec.status === 'completed' && ( +
+              {JSON.stringify(nodeExec.output, null, 2)}
+            
+ )} + {nodeExec.active_handles && nodeExec.active_handles.length > 0 && ( +
+ Branch: {nodeExec.active_handles.join(', ')} +
+ )} +
+ )} +
+ ) +} diff --git a/src/pages/workflows/components/execution/NodeExecutionDetail.tsx b/src/pages/workflows/components/execution/NodeExecutionDetail.tsx new file mode 100644 index 0000000..913786c --- /dev/null +++ b/src/pages/workflows/components/execution/NodeExecutionDetail.tsx @@ -0,0 +1,122 @@ +/** + * NodeExecutionDetail — shows detailed input/output/cost/logs for a + * running or completed node. + * + * Shown in the inspector panel when execution overlay is active and a + * node is selected. + */ + +import { Clock, DollarSign, AlertTriangle, CheckCircle2, Loader2, Ban } from 'lucide-react' +import type { WfNodeExecutionState } from '../../types' + +const SECTION_TITLE_STYLE = { + fontSize: 10, + color: 'var(--nbl-text-ghost)', + textTransform: 'uppercase' as const, + letterSpacing: '0.08em', + marginBottom: 4, +} as const + +const STATUS_ICON_MAP = { + pending: null, + queued: , + running: , + completed: , + failed: , + skipped: , + waiting: , +} as const + +interface NodeExecutionDetailProps { + nodeExecState: WfNodeExecutionState +} + +export function NodeExecutionDetail({ nodeExecState }: NodeExecutionDetailProps) { + const { status, output, error, cost_usd, duration_ms, attempt, active_handles, node_type } = nodeExecState + + return ( +
+ {/* Status header */} +
+ {STATUS_ICON_MAP[status]} + + {status} + + + {node_type} + +
+ + {/* Metrics row */} +
+ {duration_ms != null && ( + + + {duration_ms < 1000 ? `${duration_ms}ms` : `${(duration_ms / 1000).toFixed(2)}s`} + + )} + {cost_usd != null && cost_usd > 0 && ( + + + ${cost_usd.toFixed(6)} + + )} + {attempt != null && attempt > 1 && ( + Attempt #{attempt} + )} + {active_handles && active_handles.length > 0 && ( + Branch: {active_handles.join(', ')} + )} +
+ + {/* Error */} + {error && ( +
+
Error
+
+ {error} +
+
+ )} + + {/* Output */} + {output && status === 'completed' && ( +
+
Output
+
+            {JSON.stringify(output, null, 2)}
+          
+
+ )} +
+ ) +} diff --git a/src/pages/workflows/components/execution/useExecutionSSE.ts b/src/pages/workflows/components/execution/useExecutionSSE.ts new file mode 100644 index 0000000..1431398 --- /dev/null +++ b/src/pages/workflows/components/execution/useExecutionSSE.ts @@ -0,0 +1,77 @@ +/** + * useExecutionSSE — subscribes to SSE events for live execution updates. + * + * Connects to the backend SSE endpoint when an execution starts and + * dispatches events to the workflow editor store. + */ + +import { useEffect, useLayoutEffect, useRef } from 'react' +import type { WfNodeExecutionState, WfExecutionStatus } from '../../types' + +interface SSECallbacks { + onNodeStarted: (nodeId: string, state: WfNodeExecutionState) => void + onNodeCompleted: (nodeId: string, state: WfNodeExecutionState) => void + onNodeFailed: (nodeId: string, state: WfNodeExecutionState) => void + onNodeSkipped: (nodeId: string, state: WfNodeExecutionState) => void + onExecutionCompleted: (status: WfExecutionStatus, result?: Record) => void +} + +export function useExecutionSSE( + executionId: string | null, + callbacks: SSECallbacks, +) { + const callbacksRef = useRef(callbacks) + useLayoutEffect(() => { callbacksRef.current = callbacks }) + + useEffect(() => { + if (!executionId) return + + const url = `/api/workflows/executions/${executionId}/stream` + let eventSource: EventSource | null = null + + try { + eventSource = new EventSource(url) + + eventSource.addEventListener('node_started', (event: MessageEvent) => { + const data = JSON.parse(event.data) as WfNodeExecutionState + callbacksRef.current.onNodeStarted(data.node_id, data) + }) + + eventSource.addEventListener('node_completed', (event: MessageEvent) => { + const data = JSON.parse(event.data) as WfNodeExecutionState + callbacksRef.current.onNodeCompleted(data.node_id, data) + }) + + eventSource.addEventListener('node_failed', (event: MessageEvent) => { + const data = JSON.parse(event.data) as WfNodeExecutionState + callbacksRef.current.onNodeFailed(data.node_id, data) + }) + + eventSource.addEventListener('node_skipped', (event: MessageEvent) => { + const data = JSON.parse(event.data) as WfNodeExecutionState + callbacksRef.current.onNodeSkipped(data.node_id, data) + }) + + eventSource.addEventListener('execution_completed', (event: MessageEvent) => { + const data = JSON.parse(event.data) as { + status: WfExecutionStatus + total_cost_usd?: number + duration_ms?: number + } + callbacksRef.current.onExecutionCompleted(data.status, data) + eventSource?.close() + }) + + eventSource.onerror = () => { + // SSE connection lost — don't auto-reconnect for now + eventSource?.close() + } + } catch { + // EventSource not supported or URL invalid + } + + return () => { + eventSource?.close() + } + }, [executionId]) +} diff --git a/src/pages/workflows/components/workflow-node-constants.ts b/src/pages/workflows/components/workflow-node-constants.ts index 2e6b5a9..c53a9cf 100644 --- a/src/pages/workflows/components/workflow-node-constants.ts +++ b/src/pages/workflows/components/workflow-node-constants.ts @@ -3,7 +3,7 @@ import { ArrowLeftRight, Repeat, Merge, Timer, Globe, Code2, ShieldCheck, Bell, } from 'lucide-react' import type { ElementType } from 'react' -import type { WfNodeType, WfNode, WfNodeFamily } from '../types' +import type { WfNodeType, WfNode, WfNodeFamily, WfNodeExecutionState } from '../types' import { getNodeFamily } from '../types' export const NODE_W = 240 @@ -103,4 +103,5 @@ export type WfNodeData = WfNode & { selected: boolean onDelete?: (id: string) => void validationBadge?: NodeValidationBadge + execState?: WfNodeExecutionState } diff --git a/src/pages/workflows/store/workflow-editor-store.ts b/src/pages/workflows/store/workflow-editor-store.ts index bf9fd78..6cd9d1c 100644 --- a/src/pages/workflows/store/workflow-editor-store.ts +++ b/src/pages/workflows/store/workflow-editor-store.ts @@ -1,5 +1,8 @@ import { create } from 'zustand' -import type { WfDefinition, WfNode, WfEdge, WfNodeType, WfProvider, WfTrigger } from '../types' +import type { + WfDefinition, WfNode, WfEdge, WfNodeType, WfProvider, WfTrigger, + WfExecutionState, WfNodeExecutionState, WfExecutionStatus, +} from '../types' import { buildDefaultNode, findInsertionAnchor } from '../components/node-registry' import { validateConnection } from '../components/graph-validation' @@ -83,6 +86,29 @@ interface WorkflowEditorState { /** Update left panel pinned/hovered */ setLeftPanelPinned: (pinned: boolean) => void setLeftPanelHovered: (hovered: boolean) => void + + /* ── Execution state ───────────────────────────────────────────────── */ + + /** Current execution state (null when not running) */ + executionState: WfExecutionState | null + + /** Whether the execution overlay is visible */ + executionOverlayVisible: boolean + + /** Start a new execution (set state to running) */ + startExecution: (executionId: string, workflowId: string) => void + + /** Update per-node execution state (from SSE events) */ + updateNodeExecution: (nodeId: string, state: WfNodeExecutionState) => void + + /** Complete the execution (from SSE event) */ + completeExecution: (status: WfExecutionStatus, result?: Record) => void + + /** Clear execution state */ + clearExecution: () => void + + /** Toggle execution overlay visibility */ + setExecutionOverlayVisible: (visible: boolean) => void } /* ──────────────────────────────────────────────────────────────────────────── @@ -101,6 +127,8 @@ export function createWorkflowEditorStore(initialDefinition: WfDefinition) { rightPanelCollapsed: false, leftPanelPinned: true, leftPanelHovered: false, + executionState: null, + executionOverlayVisible: false, /* ── Definition replacement ──────────────────────────────────────── */ @@ -362,6 +390,77 @@ export function createWorkflowEditorStore(initialDefinition: WfDefinition) { get().pasteClipboard() }, + /* ── Execution state ────────────────────────────────────────────── */ + + startExecution: (executionId, workflowId) => { + const { definition } = get() + const nodeStates: Record = {} + for (const node of definition.nodes) { + nodeStates[node.id] = { + node_id: node.id, + node_type: node.type, + status: 'pending', + } + } + set({ + executionState: { + execution_id: executionId, + workflow_id: workflowId, + status: 'running', + node_states: nodeStates, + total_cost_usd: 0, + duration_ms: 0, + completed_nodes: 0, + total_nodes: definition.nodes.length, + started_at: new Date().toISOString(), + }, + executionOverlayVisible: true, + }) + }, + + updateNodeExecution: (nodeId, nodeState) => { + const { executionState } = get() + if (!executionState) return + const updatedNodeStates = { + ...executionState.node_states, + [nodeId]: nodeState, + } + const completedNodes = Object.values(updatedNodeStates).filter( + s => s.status === 'completed' || s.status === 'skipped', + ).length + const totalCost = Object.values(updatedNodeStates).reduce( + (sum, s) => sum + (s.cost_usd ?? 0), 0, + ) + set({ + executionState: { + ...executionState, + node_states: updatedNodeStates, + completed_nodes: completedNodes, + total_cost_usd: totalCost, + }, + }) + }, + + completeExecution: (status, result) => { + const { executionState } = get() + if (!executionState) return + set({ + executionState: { + ...executionState, + status, + duration_ms: (result?.duration_ms as number) ?? executionState.duration_ms, + total_cost_usd: (result?.total_cost_usd as number) ?? executionState.total_cost_usd, + }, + }) + }, + + clearExecution: () => set({ + executionState: null, + executionOverlayVisible: false, + }), + + setExecutionOverlayVisible: (visible) => set({ executionOverlayVisible: visible }), + /* ── Panel state ─────────────────────────────────────────────────── */ setRightPanelCollapsed: (collapsed) => set({ rightPanelCollapsed: collapsed }), diff --git a/src/pages/workflows/types.ts b/src/pages/workflows/types.ts index 81b0609..81edae8 100644 --- a/src/pages/workflows/types.ts +++ b/src/pages/workflows/types.ts @@ -207,6 +207,59 @@ export type WfDefinition = { edges: WfEdge[] } +/* ── Execution state types ──────────────────────────────────────────── */ + +export type WfNodeExecutionStatus = + | 'pending' + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'skipped' + | 'waiting' + +export type WfExecutionStatus = + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'paused' + +export type WfNodeExecutionState = { + node_id: string + node_type: string + status: WfNodeExecutionStatus + output?: Record + error?: string + cost_usd?: number + duration_ms?: number + attempt?: number + active_handles?: string[] +} + +export type WfExecutionState = { + execution_id: string + workflow_id: string + status: WfExecutionStatus + node_states: Record + total_cost_usd: number + duration_ms: number + completed_nodes: number + total_nodes: number + started_at?: string +} + +export type WfExecutionSSEEvent = { + type: + | 'execution_started' + | 'execution_completed' + | 'node_started' + | 'node_completed' + | 'node_failed' + | 'node_skipped' + data: Record +} + export function getNodeFamily(type: WfNodeType): WfNodeFamily { if (type === 'start' || type === 'end') return 'system' if (type === 'text_input' || type === 'file_input' || type === 'image_input') return 'input'
{item.model || '—'} - - - {item.stream} - - + + {item.stream} + + {fmtTokens(item.prompt_tokens)} @@ -192,7 +197,7 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN {fmtRelTime(item.created_at)}