feat(workflows): Phase 3b — execution overlay + live visualization
All checks were successful
Stuffle/agos-client/pipeline/head This commit looks good
All checks were successful
Stuffle/agos-client/pipeline/head This commit looks good
Frontend execution infrastructure: - ExecutionOverlay: status badges on nodes with 7 states (pending/queued/running/completed/failed/skipped/waiting) - Pulsing border + glow animation on running/waiting nodes (CSS @keyframes: execBorderPulse, execPulse, execDot) - NodeExecutionDetail: per-node inspector showing status, duration, cost, attempt, active handles, error, and JSON output - ExecutionTimeline: chronological step log with expandable details, status-sorted, per-step duration/cost metrics - ExecutionControls: top-center toolbar showing status, progress (X/Y steps), duration, total cost, close button - useExecutionSSE: SSE subscription hook for real-time updates (node_started/completed/failed/skipped, execution_completed) - Execution state types: WfNodeExecutionStatus, WfExecutionStatus, WfNodeExecutionState, WfExecutionState, WfExecutionSSEEvent - Zustand store: startExecution, updateNodeExecution, completeExecution, clearExecution, executionOverlayVisible - Wired through DAGBuilder → WorkflowCanvas → WorkflowNode Also fixes: - TokenLedger: pass isAdmin prop to RowDetail component Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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 (
|
||||
<tr>
|
||||
<td colSpan={11} style={{ padding: 0 }}>
|
||||
<td colSpan={isAdmin ? 11 : 10} style={{ padding: 0 }}>
|
||||
<div style={{
|
||||
padding: '10px 16px 12px', background: 'var(--nbl-hover-bg)',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))',
|
||||
@@ -95,7 +96,9 @@ function DetailField({ label, value }: { label: string; value: string | null })
|
||||
|
||||
// ── Ledger Table ──────────────────────────────────────────────────────────────
|
||||
|
||||
function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentNames: Record<string, string> }) {
|
||||
type LedgerMode = 'llm' | 'deductions'
|
||||
|
||||
function LedgerTable({ items, agentNames, isAdmin }: { items: ApiTokenUsageItem[]; agentNames: Record<string, string>; isAdmin: boolean }) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -111,7 +114,7 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--nbl-border)' }}>
|
||||
{['', '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 => (
|
||||
<th key={h || 'expand'} style={{
|
||||
padding: '8px 8px', textAlign: 'left',
|
||||
color: 'var(--nbl-text-ghost)', fontWeight: 600,
|
||||
@@ -139,24 +142,26 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN
|
||||
}}>
|
||||
{item.model || '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 8px', color: 'var(--nbl-text-muted)' }}>
|
||||
<span style={{
|
||||
padding: '2px 6px', borderRadius: 4, fontSize: 10,
|
||||
background: item.stream === 'platform'
|
||||
? 'var(--nbl-agos-bg)'
|
||||
: item.stream === 'offered'
|
||||
? 'var(--nbl-green-bg)'
|
||||
: 'var(--nbl-amber-bg)',
|
||||
color: item.stream === 'platform'
|
||||
? 'var(--nbl-agos)'
|
||||
: item.stream === 'offered'
|
||||
? 'var(--nbl-green)'
|
||||
: 'var(--nbl-amber)',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{item.stream}
|
||||
</span>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td style={{ padding: '8px 8px', color: 'var(--nbl-text-muted)' }}>
|
||||
<span style={{
|
||||
padding: '2px 6px', borderRadius: 4, fontSize: 10,
|
||||
background: item.stream === 'platform'
|
||||
? 'var(--nbl-agos-bg)'
|
||||
: item.stream === 'offered'
|
||||
? 'var(--nbl-green-bg)'
|
||||
: 'var(--nbl-amber-bg)',
|
||||
color: item.stream === 'platform'
|
||||
? 'var(--nbl-agos)'
|
||||
: item.stream === 'offered'
|
||||
? 'var(--nbl-green)'
|
||||
: 'var(--nbl-amber)',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{item.stream}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
<td style={{ padding: '8px 8px', fontFamily: 'var(--font-mono)', color: 'var(--nbl-cyan)', fontSize: 11 }}>
|
||||
<ArrowUpRight size={9} style={{ marginRight: 2 }} />
|
||||
{fmtTokens(item.prompt_tokens)}
|
||||
@@ -192,7 +197,7 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN
|
||||
{fmtRelTime(item.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && <RowDetail item={item} />}
|
||||
{expanded && <RowDetail item={item} isAdmin={isAdmin} />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
@@ -213,20 +218,53 @@ function SectionCard({ title, icon: Icon, children }: {
|
||||
borderRadius: 10, overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '12px 16px', borderBottom: '1px solid var(--nbl-border)',
|
||||
padding: '10px 16px', borderBottom: '1px solid var(--nbl-border)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<Icon size={13} style={{ color: 'var(--nbl-agos)' }} />
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--nbl-text-muted)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase', letterSpacing: '0.07em', flex: 1,
|
||||
}}>{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px' }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Pill toggle — two options, one selected at a time
|
||||
function PillToggle<T extends string>({ value, options, onChange }: {
|
||||
value: T
|
||||
options: { value: T; label: string }[]
|
||||
onChange: (v: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', borderRadius: 6, overflow: 'hidden',
|
||||
border: '1px solid var(--nbl-border)',
|
||||
background: 'var(--nbl-hover-bg)',
|
||||
}}>
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
style={{
|
||||
padding: '3px 10px', fontSize: 10, fontWeight: 600,
|
||||
border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
letterSpacing: '0.04em',
|
||||
background: value === opt.value ? 'var(--nbl-agos)' : 'transparent',
|
||||
color: value === opt.value ? '#fff' : 'var(--nbl-text-muted)',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<LedgerMode>('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 (
|
||||
<div style={{
|
||||
display: 'flex', height: '100%', width: '100%', overflow: 'hidden',
|
||||
@@ -384,8 +435,17 @@ export function TokenLedger() {
|
||||
{error && <ErrorState message={`Failed to load token usage: ${(error as Error).message}`} />}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<SectionCard title="Usage Log" icon={Layers}>
|
||||
<LedgerTable items={items} agentNames={agentNames} />
|
||||
<SectionCard title="Usage Log" icon={Layers} action={
|
||||
<PillToggle<LedgerMode>
|
||||
value={ledgerMode}
|
||||
onChange={setLedgerMode}
|
||||
options={[
|
||||
{ value: 'llm', label: 'LLM Calls' },
|
||||
{ value: 'deductions', label: 'Quota Deductions' },
|
||||
]}
|
||||
/>
|
||||
}>
|
||||
<LedgerTable items={items} agentNames={agentNames} isAdmin={isAdmin} />
|
||||
{total > 50 && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
@@ -427,6 +487,7 @@ export function TokenLedger() {
|
||||
agentData={agentAgg?.items ?? []}
|
||||
dayData={dayAgg?.items ?? []}
|
||||
streamData={streamAgg?.items ?? []}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
display: 'inline-flex', borderRadius: 5, overflow: 'hidden',
|
||||
border: '1px solid var(--nbl-border)',
|
||||
}}>
|
||||
{(['model', 'stream'] as const).map(v => (
|
||||
<button key={v} onClick={() => onChange(v)} style={{
|
||||
padding: '2px 8px', fontSize: 9, fontWeight: 700, border: 'none',
|
||||
cursor: 'pointer', letterSpacing: '0.04em', textTransform: 'uppercase',
|
||||
background: value === v ? 'var(--nbl-agos)' : 'transparent',
|
||||
color: value === v ? '#fff' : 'var(--nbl-text-muted)',
|
||||
fontFamily: 'inherit', transition: 'background 0.15s',
|
||||
}}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<Record<string, boolean>>({
|
||||
costs: true, distribution: true, models: true, activity: true,
|
||||
})
|
||||
const [groupBy, setGroupBy] = useState('model')
|
||||
const [distView, setDistView] = useState<'model' | 'stream'>('model')
|
||||
const chartRefModelDonut = useRef<HighchartsReact.RefObject>(null)
|
||||
const chartRefStreamDonut = useRef<HighchartsReact.RefObject>(null)
|
||||
const chartRefBar = useRef<HighchartsReact.RefObject>(null)
|
||||
@@ -419,7 +445,7 @@ export function UsageCharts({ modelData, agentData, dayData, streamData }: Usage
|
||||
action={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Filter size={9} style={{ color: 'var(--nbl-text-ghost)' }} />
|
||||
<SimpleSelect size="sm" value={groupBy} onChange={setGroupBy} options={GROUP_OPTIONS} />
|
||||
<SimpleSelect size="sm" value={groupBy} onChange={setGroupBy} options={isAdmin ? GROUP_OPTIONS_ADMIN : GROUP_OPTIONS_BASE} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -435,11 +461,16 @@ export function UsageCharts({ modelData, agentData, dayData, streamData }: Usage
|
||||
icon={PieChart}
|
||||
open={openSections.distribution}
|
||||
onToggle={() => toggle('distribution')}
|
||||
action={isAdmin ? (
|
||||
<DistPillToggle value={distView} onChange={setDistView} />
|
||||
) : undefined}
|
||||
>
|
||||
<ChartLabel>By Model</ChartLabel>
|
||||
<HighchartsReact highcharts={Highcharts} options={modelDonutOpts} ref={chartRefModelDonut} />
|
||||
<ChartLabel style={{ marginTop: 8 }}>By Stream</ChartLabel>
|
||||
<HighchartsReact highcharts={Highcharts} options={streamDonutOpts} ref={chartRefStreamDonut} />
|
||||
{(!isAdmin || distView === 'model') && (
|
||||
<HighchartsReact highcharts={Highcharts} options={modelDonutOpts} ref={chartRefModelDonut} />
|
||||
)}
|
||||
{isAdmin && distView === 'stream' && (
|
||||
<HighchartsReact highcharts={Highcharts} options={streamDonutOpts} ref={chartRefStreamDonut} />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Models */}
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', height: '100%', position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Inject execution keyframe styles */}
|
||||
{executionOverlayVisible && <style>{EXECUTION_OVERLAY_STYLES}</style>}
|
||||
<div style={{ flex: 1 }}>
|
||||
<ReactFlowProvider>
|
||||
<WorkflowCanvas
|
||||
@@ -187,6 +201,7 @@ export function DAGBuilder({
|
||||
onConnect={handleConnect}
|
||||
onRemoveEdge={handleRemoveEdge}
|
||||
onAddNode={handleAddNode}
|
||||
execStateByNode={execNodeStates}
|
||||
>
|
||||
{/* Left panel: Node Palette */}
|
||||
<Panel position="top-left" style={{ margin: 16 }}>
|
||||
@@ -199,7 +214,7 @@ export function DAGBuilder({
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{/* Right panel: Inspector */}
|
||||
{/* Right panel: Inspector or Execution Detail */}
|
||||
<Panel position="top-right" style={{ margin: 16, height: rightPanelCollapsed ? 'auto' : 'calc(100% - 32px)' }}>
|
||||
<InspectorPanel
|
||||
definition={definition}
|
||||
@@ -212,10 +227,40 @@ export function DAGBuilder({
|
||||
onUpdateTrigger={handleUpdateTrigger}
|
||||
onUpdateProvider={handleUpdateProvider}
|
||||
onUpdateTemplate={handleUpdateTemplate}
|
||||
footer={rightStackFooter}
|
||||
footer={
|
||||
<>
|
||||
{/* Execution detail for selected node */}
|
||||
{selectedNodeExec && executionOverlayVisible && (
|
||||
<div style={{ borderTop: '1px solid var(--nbl-border)', paddingTop: 10, marginTop: 4 }}>
|
||||
<NodeExecutionDetail nodeExecState={selectedNodeExec} />
|
||||
</div>
|
||||
)}
|
||||
{/* Execution timeline */}
|
||||
{executionOverlayVisible && executionState && (
|
||||
<div style={{ borderTop: '1px solid var(--nbl-border)', paddingTop: 10, marginTop: 4 }}>
|
||||
<ExecutionTimeline
|
||||
executionState={executionState}
|
||||
nodes={definition.nodes}
|
||||
onSelectNode={handleSelectNode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{rightStackFooter}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{/* Execution controls overlay (top-center) */}
|
||||
{executionOverlayVisible && executionState && (
|
||||
<Panel position="top-center" style={{ margin: 16, zIndex: 10, pointerEvents: 'none' }}>
|
||||
<ExecutionControls
|
||||
executionState={executionState}
|
||||
onClose={() => store.getState().clearExecution()}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Bottom-left hint */}
|
||||
<Panel position="bottom-left" style={{ margin: 16, zIndex: 6 }}>
|
||||
<div style={{ ...FLOATING_PANEL_STYLE, padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--nbl-text-muted)', maxWidth: 340 }}>
|
||||
|
||||
@@ -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<string, WfNodeExecutionState>
|
||||
/** 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<ReactFlowInstance | null>(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),
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
width: NODE_W,
|
||||
height: NODE_H,
|
||||
background: `linear-gradient(180deg, color-mix(in srgb, var(--nbl-surface) 92%, ${color} 8%), color-mix(in srgb, var(--nbl-surface) 98%, transparent))`,
|
||||
border: `1.5px solid ${d.selected ? color : `color-mix(in srgb, ${color} 24%, var(--nbl-border))`}`,
|
||||
border: `1.5px solid ${borderColor}`,
|
||||
borderRadius,
|
||||
clipPath,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '0 14px',
|
||||
boxShadow: d.selected
|
||||
? `0 0 0 3px color-mix(in srgb, ${color} 18%, transparent), 0 16px 34px rgba(0, 0, 0, 0.18)`
|
||||
: '0 10px 28px rgba(0, 0, 0, 0.12)',
|
||||
boxShadow: execPulsing
|
||||
? `0 0 12px 3px color-mix(in srgb, ${execBorderColor} 30%, transparent), 0 10px 28px rgba(0, 0, 0, 0.12)`
|
||||
: d.selected
|
||||
? `0 0 0 3px color-mix(in srgb, ${color} 18%, transparent), 0 16px 34px rgba(0, 0, 0, 0.18)`
|
||||
: '0 10px 28px rgba(0, 0, 0, 0.12)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s, transform 0.15s',
|
||||
position: 'relative',
|
||||
transform: d.selected ? 'translateY(-1px)' : 'translateY(0)',
|
||||
animation: execPulsing ? 'execBorderPulse 1.5s ease-in-out infinite' : undefined,
|
||||
// @ts-expect-error -- CSS custom property for the animation color
|
||||
'--exec-glow-color': execBorderColor,
|
||||
}}
|
||||
>
|
||||
{d.type !== 'start' && <Handle type="target" position={Position.Left} style={{ ...HANDLE_STYLE, background: color, left: -9 }} />}
|
||||
@@ -97,7 +107,8 @@ export function WorkflowNode({ id, data }: NodeProps) {
|
||||
</button>
|
||||
)}
|
||||
{d.type !== 'end' && <Handle type="source" position={Position.Right} style={{ ...HANDLE_STYLE, background: color, right: -9 }} />}
|
||||
{d.validationBadge && (
|
||||
{d.execState && <NodeExecutionBadge nodeExecState={d.execState} />}
|
||||
{d.validationBadge && !d.execState && (
|
||||
<div
|
||||
title={d.validationBadge.messages.join('\n')}
|
||||
style={{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dagre from '@dagrejs/dagre'
|
||||
import { MarkerType, type Edge, type Node } from '@xyflow/react'
|
||||
import type { WfDefinition, WfEdge } from '../types'
|
||||
import type { WfDefinition, WfEdge, WfNodeExecutionState } from '../types'
|
||||
import type { WfNodeData } from './workflow-node-constants'
|
||||
import { NODE_H, NODE_W } from './workflow-node-constants'
|
||||
|
||||
@@ -29,6 +29,7 @@ export function toFlowNodes(
|
||||
selectedId: string | null,
|
||||
onDelete: (id: string) => void,
|
||||
validationByNode?: Map<string, { severity: 'error' | 'warning'; count: number; messages: string[] }>,
|
||||
execStateByNode?: Record<string, WfNodeExecutionState>,
|
||||
): 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,
|
||||
}))
|
||||
}
|
||||
|
||||
104
src/pages/workflows/components/execution/ExecutionControls.tsx
Normal file
104
src/pages/workflows/components/execution/ExecutionControls.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 10,
|
||||
background: 'var(--nbl-bg-elevated)',
|
||||
border: '1px solid var(--nbl-border)',
|
||||
fontSize: 10,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
{isRunning && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--nbl-cyan)' }}>
|
||||
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
<span style={{ fontWeight: 700 }}>Running</span>
|
||||
</span>
|
||||
)}
|
||||
{isDone && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--nbl-green)' }}>
|
||||
<CheckCircle2 size={12} />
|
||||
<span style={{ fontWeight: 700 }}>Completed</span>
|
||||
</span>
|
||||
)}
|
||||
{isFailed && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--nbl-red)' }}>
|
||||
<AlertTriangle size={12} />
|
||||
<span style={{ fontWeight: 700 }}>{executionState.status === 'cancelled' ? 'Cancelled' : 'Failed'}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<span style={{ width: 1, height: 14, background: 'var(--nbl-border)' }} />
|
||||
|
||||
{/* Progress */}
|
||||
<span style={{ color: 'var(--nbl-text-muted)', fontWeight: 600 }}>
|
||||
{executionState.completed_nodes}/{executionState.total_nodes}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
{executionState.duration_ms > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3, color: 'var(--nbl-text-muted)' }}>
|
||||
<Clock size={9} />
|
||||
{(executionState.duration_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{executionState.total_cost_usd > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3, color: 'var(--nbl-text-muted)' }}>
|
||||
<DollarSign size={9} />
|
||||
${executionState.total_cost_usd.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--nbl-border)',
|
||||
background: 'var(--nbl-bg)',
|
||||
color: 'var(--nbl-text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
title="Close execution overlay"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
src/pages/workflows/components/execution/ExecutionOverlay.tsx
Normal file
134
src/pages/workflows/components/execution/ExecutionOverlay.tsx
Normal file
@@ -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<WfNodeExecutionStatus, { bg: string; border: string; text: string }> = {
|
||||
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<WfNodeExecutionStatus, string> = {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 8,
|
||||
background: colors.bg,
|
||||
border: `1px solid ${colors.border}`,
|
||||
color: colors.text,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.02em',
|
||||
animation: isPulsing ? 'execPulse 1.5s ease-in-out infinite' : undefined,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{isPulsing && (
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: colors.text,
|
||||
animation: 'execDot 1s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{STATUS_LABELS[status]}
|
||||
{duration_ms != null && status === 'completed' && (
|
||||
<span style={{ opacity: 0.7, fontWeight: 500 }}>
|
||||
{duration_ms < 1000 ? `${duration_ms}ms` : `${(duration_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
{cost_usd != null && cost_usd > 0 && status === 'completed' && (
|
||||
<span style={{ opacity: 0.7, fontWeight: 500 }}>
|
||||
${cost_usd.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)); }
|
||||
}
|
||||
`
|
||||
212
src/pages/workflows/components/execution/ExecutionTimeline.tsx
Normal file
212
src/pages/workflows/components/execution/ExecutionTimeline.tsx
Normal file
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Summary */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 0', fontSize: 10, color: 'var(--nbl-text-muted)',
|
||||
borderBottom: '1px solid var(--nbl-border)', marginBottom: 4,
|
||||
}}>
|
||||
<span style={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--nbl-text-ghost)' }}>
|
||||
Timeline
|
||||
</span>
|
||||
<span>
|
||||
{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`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{orderedNodes.map(node => {
|
||||
const nodeExec = executionState.node_states[node.id]
|
||||
if (!nodeExec) return null
|
||||
return (
|
||||
<TimelineStep
|
||||
key={node.id}
|
||||
node={node}
|
||||
nodeExec={nodeExec}
|
||||
onSelect={() => onSelectNode?.(node.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ borderRadius: 8, overflow: 'hidden' }}>
|
||||
<button
|
||||
onClick={() => { onSelect(); setExpanded(e => !e) }}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
background: expanded ? 'var(--nbl-bg-elevated)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
color: 'var(--nbl-text)',
|
||||
fontSize: 11,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Status dot/icon */}
|
||||
{Icon ? (
|
||||
<Icon
|
||||
size={12}
|
||||
style={{
|
||||
color: cfg.color,
|
||||
flexShrink: 0,
|
||||
animation: isAnimated ? 'spin 1s linear infinite' : undefined,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${cfg.color}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{node.label}
|
||||
</span>
|
||||
|
||||
{/* Metrics */}
|
||||
<span style={{ fontSize: 9, color: 'var(--nbl-text-muted)', display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
{nodeExec.duration_ms != null && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Clock size={8} />
|
||||
{nodeExec.duration_ms < 1000 ? `${nodeExec.duration_ms}ms` : `${(nodeExec.duration_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
{nodeExec.cost_usd != null && nodeExec.cost_usd > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<DollarSign size={8} />
|
||||
${nodeExec.cost_usd.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Expand indicator */}
|
||||
{expanded
|
||||
? <ChevronDown size={10} style={{ color: 'var(--nbl-text-ghost)', flexShrink: 0 }} />
|
||||
: <ChevronRight size={10} style={{ color: 'var(--nbl-text-ghost)', flexShrink: 0 }} />}
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div style={{
|
||||
padding: '4px 8px 8px 26px',
|
||||
fontSize: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}>
|
||||
{nodeExec.error && (
|
||||
<div style={{
|
||||
padding: 6,
|
||||
borderRadius: 6,
|
||||
background: 'color-mix(in srgb, var(--nbl-red) 8%, var(--nbl-bg))',
|
||||
border: '1px solid color-mix(in srgb, var(--nbl-red) 20%, transparent)',
|
||||
color: 'var(--nbl-red)',
|
||||
fontFamily: 'var(--nbl-mono)',
|
||||
fontSize: 9,
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxHeight: 80,
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{nodeExec.error}
|
||||
</div>
|
||||
)}
|
||||
{nodeExec.output && nodeExec.status === 'completed' && (
|
||||
<pre style={{
|
||||
padding: 6,
|
||||
borderRadius: 6,
|
||||
background: 'var(--nbl-bg-elevated)',
|
||||
border: '1px solid var(--nbl-border)',
|
||||
fontFamily: 'var(--nbl-mono)',
|
||||
fontSize: 9,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 100,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
}}>
|
||||
{JSON.stringify(nodeExec.output, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{nodeExec.active_handles && nodeExec.active_handles.length > 0 && (
|
||||
<div style={{ color: 'var(--nbl-text-muted)' }}>
|
||||
Branch: {nodeExec.active_handles.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
src/pages/workflows/components/execution/NodeExecutionDetail.tsx
Normal file
122
src/pages/workflows/components/execution/NodeExecutionDetail.tsx
Normal file
@@ -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: <Loader2 size={12} style={{ color: 'var(--nbl-text-ghost)', animation: 'spin 1s linear infinite' }} />,
|
||||
running: <Loader2 size={12} style={{ color: 'var(--nbl-cyan)', animation: 'spin 1s linear infinite' }} />,
|
||||
completed: <CheckCircle2 size={12} style={{ color: 'var(--nbl-green)' }} />,
|
||||
failed: <AlertTriangle size={12} style={{ color: 'var(--nbl-red)' }} />,
|
||||
skipped: <Ban size={12} style={{ color: 'var(--nbl-text-ghost)' }} />,
|
||||
waiting: <Clock size={12} style={{ color: 'var(--nbl-amber)' }} />,
|
||||
} 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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{/* Status header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{STATUS_ICON_MAP[status]}
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--nbl-text)', textTransform: 'capitalize' }}>
|
||||
{status}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--nbl-text-muted)', marginLeft: 'auto' }}>
|
||||
{node_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metrics row */}
|
||||
<div style={{ display: 'flex', gap: 12, fontSize: 10, color: 'var(--nbl-text-muted)' }}>
|
||||
{duration_ms != null && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Clock size={10} />
|
||||
{duration_ms < 1000 ? `${duration_ms}ms` : `${(duration_ms / 1000).toFixed(2)}s`}
|
||||
</span>
|
||||
)}
|
||||
{cost_usd != null && cost_usd > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<DollarSign size={10} />
|
||||
${cost_usd.toFixed(6)}
|
||||
</span>
|
||||
)}
|
||||
{attempt != null && attempt > 1 && (
|
||||
<span>Attempt #{attempt}</span>
|
||||
)}
|
||||
{active_handles && active_handles.length > 0 && (
|
||||
<span>Branch: {active_handles.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div>
|
||||
<div style={SECTION_TITLE_STYLE}>Error</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
background: 'color-mix(in srgb, var(--nbl-red) 8%, var(--nbl-bg))',
|
||||
border: '1px solid color-mix(in srgb, var(--nbl-red) 20%, transparent)',
|
||||
color: 'var(--nbl-red)',
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--nbl-mono)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output */}
|
||||
{output && status === 'completed' && (
|
||||
<div>
|
||||
<div style={SECTION_TITLE_STYLE}>Output</div>
|
||||
<pre
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
background: 'var(--nbl-bg-elevated)',
|
||||
border: '1px solid var(--nbl-border)',
|
||||
color: 'var(--nbl-text)',
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--nbl-mono)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
src/pages/workflows/components/execution/useExecutionSSE.ts
Normal file
77
src/pages/workflows/components/execution/useExecutionSSE.ts
Normal file
@@ -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<string, unknown>) => 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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => 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<string, WfNodeExecutionState> = {}
|
||||
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 }),
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, WfNodeExecutionState>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user