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 => (
|
{item.model || '—'}
- |
-
- {item.stream}
-
- |
+ {isAdmin && (
+
+
+ {item.stream}
+
+ |
+ )}
{fmtTokens(item.prompt_tokens)}
@@ -192,7 +197,7 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN
{fmtRelTime(item.created_at)}
|
- {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 && (
+
+ )}
+
+ {/* 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'
|