diff --git a/COMPONENT_GUIDE.md b/COMPONENT_GUIDE.md index 7e19fed..9753c23 100644 --- a/COMPONENT_GUIDE.md +++ b/COMPONENT_GUIDE.md @@ -207,8 +207,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/ | MonoText | primitive | Inline monospace text (xs, sm, md) | | Pagination | primitive | Page navigation controls | | Popover | composite | Click-triggered floating panel with arrow | -| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? | -| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? | +| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. | +| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. | | ProgressBar | primitive | Determinate/indeterminate progress indicator | | RadioGroup | primitive | Single-select option group (use with RadioItem) | | RadioItem | primitive | Individual radio option within RadioGroup | diff --git a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css index ee98585..187b1ba 100644 --- a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css +++ b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css @@ -96,6 +96,39 @@ padding: 2px 0 2px 4px; } +/* Action trigger — hidden by default, shown on hover/selected */ +.actionsTrigger { + opacity: 0; + transition: opacity 0.1s; + flex-shrink: 0; + display: flex; + align-items: center; +} + +.row:hover .actionsTrigger, +.actionsVisible { + opacity: 1; +} + +.actionsBtn { + background: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + padding: 0 4px; + font-size: 14px; + line-height: 1; + color: var(--text-muted); + transition: all 0.1s; + font-family: var(--font-body); +} + +.actionsBtn:hover { + background: var(--bg-hover); + border-color: var(--border-subtle); + color: var(--text-primary); +} + .empty { color: var(--text-muted); font-size: 12px; diff --git a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.test.tsx b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.test.tsx new file mode 100644 index 0000000..3ece70c --- /dev/null +++ b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ProcessorTimeline } from './ProcessorTimeline' + +const processors = [ + { name: 'Validate', type: 'validator', durationMs: 12, status: 'ok' as const, startMs: 0 }, + { name: 'Enrich', type: 'enricher', durationMs: 35, status: 'slow' as const, startMs: 12 }, + { name: 'Route', type: 'router', durationMs: 8, status: 'fail' as const, startMs: 47 }, +] + +describe('ProcessorTimeline', () => { + it('renders processor names', () => { + render() + expect(screen.getByText('Validate')).toBeInTheDocument() + expect(screen.getByText('Enrich')).toBeInTheDocument() + expect(screen.getByText('Route')).toBeInTheDocument() + }) + + it('does not render action trigger when no actions provided', () => { + const { container } = render( + , + ) + expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument() + }) + + it('renders action trigger when actions provided', () => { + const { container } = render( + {} }]} + />, + ) + const triggers = container.querySelectorAll('[aria-label*="Actions for"]') + expect(triggers.length).toBe(3) + }) + + it('clicking action trigger does not fire onProcessorClick', async () => { + const onProcessorClick = vi.fn() + const user = userEvent.setup() + const { container } = render( + {} }]} + />, + ) + const trigger = container.querySelector('[aria-label="Actions for Validate"]')! + await user.click(trigger) + expect(onProcessorClick).not.toHaveBeenCalled() + }) + + it('calls action onClick when menu item clicked', async () => { + const actionClick = vi.fn() + const user = userEvent.setup() + const { container } = render( + , + ) + const trigger = container.querySelector('[aria-label="Actions for Validate"]')! + await user.click(trigger) + await user.click(screen.getByText('Change Log Level')) + expect(actionClick).toHaveBeenCalledOnce() + }) + + it('supports dynamic getActions per processor', () => { + const { container } = render( + + proc.status === 'fail' + ? [{ label: 'View Error', onClick: () => {} }] + : [] + } + />, + ) + // Only the failing processor should have an action trigger + const triggers = container.querySelectorAll('[aria-label*="Actions for"]') + expect(triggers.length).toBe(1) + expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for Route') + }) +}) diff --git a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx index 0bd3c71..937aebe 100644 --- a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx +++ b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx @@ -1,4 +1,6 @@ +import type { ReactNode } from 'react' import styles from './ProcessorTimeline.module.css' +import { Dropdown } from '../Dropdown/Dropdown' export interface ProcessorStep { name: string @@ -8,11 +10,21 @@ export interface ProcessorStep { startMs: number } +export interface ProcessorAction { + label: string + icon?: ReactNode + onClick: () => void + disabled?: boolean + divider?: boolean +} + interface ProcessorTimelineProps { processors: ProcessorStep[] totalMs: number onProcessorClick?: (processor: ProcessorStep, index: number) => void selectedIndex?: number + actions?: ProcessorAction[] + getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[] className?: string } @@ -26,6 +38,8 @@ export function ProcessorTimeline({ totalMs, onProcessorClick, selectedIndex, + actions, + getActions, className, }: ProcessorTimelineProps) { const safeTotal = totalMs || 1 @@ -82,6 +96,30 @@ export function ProcessorTimeline({
{formatDuration(proc.durationMs)}
+ {(() => { + const resolvedActions = getActions ? getActions(proc, i) : (actions ?? []) + if (resolvedActions.length === 0) return null + return ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + ⋮ + + } + items={resolvedActions} + /> +
+ ) + })()} ) })} diff --git a/src/design-system/composites/RouteFlow/RouteFlow.module.css b/src/design-system/composites/RouteFlow/RouteFlow.module.css index 775ee06..438d8d0 100644 --- a/src/design-system/composites/RouteFlow/RouteFlow.module.css +++ b/src/design-system/composites/RouteFlow/RouteFlow.module.css @@ -188,6 +188,40 @@ outline-offset: 2px; } +/* Action trigger — hidden by default, shown on hover/selected */ +.actionsTrigger { + opacity: 0; + transition: opacity 0.1s; + flex-shrink: 0; + display: flex; + align-items: center; + margin-left: 4px; +} + +.node:hover .actionsTrigger, +.actionsVisible { + opacity: 1; +} + +.actionsBtn { + background: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + padding: 0 4px; + font-size: 14px; + line-height: 1; + color: var(--text-muted); + transition: all 0.1s; + font-family: var(--font-body); +} + +.actionsBtn:hover { + background: var(--bg-hover); + border-color: var(--border-subtle); + color: var(--text-primary); +} + /* Bottleneck badge */ .bottleneckBadge { position: absolute; diff --git a/src/design-system/composites/RouteFlow/RouteFlow.test.tsx b/src/design-system/composites/RouteFlow/RouteFlow.test.tsx new file mode 100644 index 0000000..bd03bf6 --- /dev/null +++ b/src/design-system/composites/RouteFlow/RouteFlow.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { RouteFlow } from './RouteFlow' + +const nodes = [ + { name: 'jms:orders', type: 'from' as const, durationMs: 4, status: 'ok' as const }, + { name: 'OrderValidator', type: 'process' as const, durationMs: 8, status: 'ok' as const }, + { name: 'http:payment-api', type: 'to' as const, durationMs: 187, status: 'slow' as const }, + { name: 'dead-letter:failed', type: 'error-handler' as const, durationMs: 14, status: 'fail' as const }, +] + +describe('RouteFlow', () => { + it('renders node names', () => { + render() + expect(screen.getByText('jms:orders')).toBeInTheDocument() + expect(screen.getByText('OrderValidator')).toBeInTheDocument() + expect(screen.getByText('http:payment-api')).toBeInTheDocument() + expect(screen.getByText('dead-letter:failed')).toBeInTheDocument() + }) + + it('does not render action trigger when no actions provided', () => { + const { container } = render() + expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument() + }) + + it('renders action trigger on all nodes including error handlers when actions provided', () => { + const { container } = render( + {} }]} + />, + ) + const triggers = container.querySelectorAll('[aria-label*="Actions for"]') + expect(triggers.length).toBe(4) // 3 main + 1 error handler + }) + + it('clicking action trigger does not fire onNodeClick', async () => { + const onNodeClick = vi.fn() + const user = userEvent.setup() + const { container } = render( + {} }]} + />, + ) + const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')! + await user.click(trigger) + expect(onNodeClick).not.toHaveBeenCalled() + }) + + it('calls action onClick when menu item clicked', async () => { + const actionClick = vi.fn() + const user = userEvent.setup() + const { container } = render( + , + ) + const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')! + await user.click(trigger) + await user.click(screen.getByText('Change Log Level')) + expect(actionClick).toHaveBeenCalledOnce() + }) + + it('supports dynamic getActions per node', () => { + const { container } = render( + + node.type === 'process' + ? [{ label: 'Edit Processor', onClick: () => {} }] + : [] + } + />, + ) + const triggers = container.querySelectorAll('[aria-label*="Actions for"]') + expect(triggers.length).toBe(1) + expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator') + }) +}) diff --git a/src/design-system/composites/RouteFlow/RouteFlow.tsx b/src/design-system/composites/RouteFlow/RouteFlow.tsx index ca9e0ac..b166839 100644 --- a/src/design-system/composites/RouteFlow/RouteFlow.tsx +++ b/src/design-system/composites/RouteFlow/RouteFlow.tsx @@ -1,4 +1,6 @@ +import type { ReactNode } from 'react' import styles from './RouteFlow.module.css' +import { Dropdown } from '../Dropdown/Dropdown' export interface RouteNode { name: string @@ -8,10 +10,20 @@ export interface RouteNode { isBottleneck?: boolean } +export interface NodeAction { + label: string + icon?: ReactNode + onClick: () => void + disabled?: boolean + divider?: boolean +} + interface RouteFlowProps { nodes: RouteNode[] onNodeClick?: (node: RouteNode, index: number) => void selectedIndex?: number + actions?: NodeAction[] + getActions?: (node: RouteNode, index: number) => NodeAction[] className?: string } @@ -52,7 +64,38 @@ function nodeStatusClass(node: RouteNode): string { return styles.nodeHealthy } -export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) { +function renderActionTrigger( + node: RouteNode, + index: number, + isSelected: boolean, + actions?: NodeAction[], + getActions?: (node: RouteNode, index: number) => NodeAction[], +) { + const resolvedActions = getActions ? getActions(node, index) : (actions ?? []) + if (resolvedActions.length === 0) return null + return ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + ⋮ + + } + items={resolvedActions} + /> +
+ ) +} + +export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) { const mainNodes = nodes.filter((n) => n.type !== 'error-handler') const errorHandlers = nodes.filter((n) => n.type === 'error-handler') @@ -102,6 +145,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout {formatDuration(node.durationMs)} + {renderActionTrigger(node, originalIndex, isSelected, actions, getActions)} ) @@ -110,22 +154,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout {errorHandlers.length > 0 && (
Error Handler
- {errorHandlers.map((node, i) => ( -
-
- {TYPE_ICONS['error-handler']} -
-
-
{node.type}
-
{node.name}
-
-
-
- {formatDuration(node.durationMs)} + {errorHandlers.map((node, i) => { + const errOriginalIndex = nodes.indexOf(node) + return ( +
+
+ {TYPE_ICONS['error-handler']}
+
+
{node.type}
+
{node.name}
+
+
+
+ {formatDuration(node.durationMs)} +
+
+ {renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
-
- ))} + ) + })}
)}
diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 6578721..4fc92a8 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -32,9 +32,9 @@ export { MultiSelect } from './MultiSelect/MultiSelect' export type { MultiSelectOption } from './MultiSelect/MultiSelect' export { Popover } from './Popover/Popover' export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline' -export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline' +export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline' export { RouteFlow } from './RouteFlow/RouteFlow' -export type { RouteNode } from './RouteFlow/RouteFlow' +export type { RouteNode, NodeAction } from './RouteFlow/RouteFlow' export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar' export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs' export { SplitPane } from './SplitPane/SplitPane' diff --git a/src/pages/Inventory/sections/CompositesSection.tsx b/src/pages/Inventory/sections/CompositesSection.tsx index 3d938e2..828a7f3 100644 --- a/src/pages/Inventory/sections/CompositesSection.tsx +++ b/src/pages/Inventory/sections/CompositesSection.tsx @@ -769,7 +769,7 @@ export function CompositesSection() {
[ + { label: 'Change Log Level', onClick: () => {} }, + { label: 'View Configuration', onClick: () => {} }, + ...(proc.status === 'fail' ? [{ label: 'View Stack Trace', onClick: () => {} }] : []), + ]} />
@@ -788,7 +793,7 @@ export function CompositesSection() {
{} }, + { label: 'Enable Tracing', onClick: () => {} }, + ]} />