feat: add registerable actions to ProcessorTimeline and RouteFlow
All checks were successful
Build & Publish / publish (push) Successful in 51s
All checks were successful
Build & Publish / publish (push) Successful in 51s
Both components now accept `actions` (static) or `getActions` (dynamic per-item) props. When provided, a "⋮" trigger appears on hover that opens a Dropdown menu. Click propagation is stopped so action clicks don't fire the row/node click handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -207,8 +207,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||||
| Pagination | primitive | Page navigation controls |
|
| Pagination | primitive | Page navigation controls |
|
||||||
| Popover | composite | Click-triggered floating panel with arrow |
|
| Popover | composite | Click-triggered floating panel with arrow |
|
||||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, 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, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
| 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 |
|
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||||
|
|||||||
@@ -96,6 +96,39 @@
|
|||||||
padding: 2px 0 2px 4px;
|
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 {
|
.empty {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -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(<ProcessorTimeline processors={processors} totalMs={55} />)
|
||||||
|
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(
|
||||||
|
<ProcessorTimeline processors={processors} totalMs={55} />,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders action trigger when actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
onProcessorClick={onProcessorClick}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
getActions={(proc) =>
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import styles from './ProcessorTimeline.module.css'
|
import styles from './ProcessorTimeline.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
|
||||||
export interface ProcessorStep {
|
export interface ProcessorStep {
|
||||||
name: string
|
name: string
|
||||||
@@ -8,11 +10,21 @@ export interface ProcessorStep {
|
|||||||
startMs: number
|
startMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProcessorAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface ProcessorTimelineProps {
|
interface ProcessorTimelineProps {
|
||||||
processors: ProcessorStep[]
|
processors: ProcessorStep[]
|
||||||
totalMs: number
|
totalMs: number
|
||||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: ProcessorAction[]
|
||||||
|
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +38,8 @@ export function ProcessorTimeline({
|
|||||||
totalMs,
|
totalMs,
|
||||||
onProcessorClick,
|
onProcessorClick,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
actions,
|
||||||
|
getActions,
|
||||||
className,
|
className,
|
||||||
}: ProcessorTimelineProps) {
|
}: ProcessorTimelineProps) {
|
||||||
const safeTotal = totalMs || 1
|
const safeTotal = totalMs || 1
|
||||||
@@ -82,6 +96,30 @@ export function ProcessorTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
||||||
|
{(() => {
|
||||||
|
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
|
||||||
|
if (resolvedActions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${proc.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -188,6 +188,40 @@
|
|||||||
outline-offset: 2px;
|
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 */
|
/* Bottleneck badge */
|
||||||
.bottleneckBadge {
|
.bottleneckBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
83
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
83
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
@@ -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(<RouteFlow nodes={nodes} />)
|
||||||
|
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(<RouteFlow nodes={nodes} />)
|
||||||
|
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(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
actions={[{ label: 'View Config', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
getActions={(node) =>
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import styles from './RouteFlow.module.css'
|
import styles from './RouteFlow.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
|
||||||
export interface RouteNode {
|
export interface RouteNode {
|
||||||
name: string
|
name: string
|
||||||
@@ -8,10 +10,20 @@ export interface RouteNode {
|
|||||||
isBottleneck?: boolean
|
isBottleneck?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodeAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface RouteFlowProps {
|
interface RouteFlowProps {
|
||||||
nodes: RouteNode[]
|
nodes: RouteNode[]
|
||||||
onNodeClick?: (node: RouteNode, index: number) => void
|
onNodeClick?: (node: RouteNode, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: NodeAction[]
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +64,38 @@ function nodeStatusClass(node: RouteNode): string {
|
|||||||
return styles.nodeHealthy
|
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 (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${node.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
||||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
||||||
const errorHandlers = 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)}
|
{formatDuration(node.durationMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -110,22 +154,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{errorHandlers.length > 0 && (
|
{errorHandlers.length > 0 && (
|
||||||
<div className={styles.errorSection}>
|
<div className={styles.errorSection}>
|
||||||
<div className={styles.errorLabel}>Error Handler</div>
|
<div className={styles.errorLabel}>Error Handler</div>
|
||||||
{errorHandlers.map((node, i) => (
|
{errorHandlers.map((node, i) => {
|
||||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
const errOriginalIndex = nodes.indexOf(node)
|
||||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
return (
|
||||||
{TYPE_ICONS['error-handler']}
|
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||||
</div>
|
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||||
<div className={styles.info}>
|
{TYPE_ICONS['error-handler']}
|
||||||
<div className={styles.type}>{node.type}</div>
|
|
||||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.stats}>
|
|
||||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
|
||||||
{formatDuration(node.durationMs)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.type}>{node.type}</div>
|
||||||
|
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||||
|
{formatDuration(node.durationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ export { MultiSelect } from './MultiSelect/MultiSelect'
|
|||||||
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||||
export { Popover } from './Popover/Popover'
|
export { Popover } from './Popover/Popover'
|
||||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
export type { RouteNode, NodeAction } from './RouteFlow/RouteFlow'
|
||||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||||
export { SplitPane } from './SplitPane/SplitPane'
|
export { SplitPane } from './SplitPane/SplitPane'
|
||||||
|
|||||||
@@ -769,7 +769,7 @@ export function CompositesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="processortimeline"
|
id="processortimeline"
|
||||||
title="ProcessorTimeline"
|
title="ProcessorTimeline"
|
||||||
description="Horizontal Gantt-style timeline showing processor execution order, duration, and status."
|
description="Horizontal Gantt-style timeline with selectable rows and optional action menus via actions or getActions prop."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<ProcessorTimeline
|
<ProcessorTimeline
|
||||||
@@ -780,6 +780,11 @@ export function CompositesSection() {
|
|||||||
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
||||||
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
||||||
]}
|
]}
|
||||||
|
getActions={(proc) => [
|
||||||
|
{ label: 'Change Log Level', onClick: () => {} },
|
||||||
|
{ label: 'View Configuration', onClick: () => {} },
|
||||||
|
...(proc.status === 'fail' ? [{ label: 'View Stack Trace', onClick: () => {} }] : []),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -788,7 +793,7 @@ export function CompositesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="routeflow"
|
id="routeflow"
|
||||||
title="RouteFlow"
|
title="RouteFlow"
|
||||||
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
|
description="Vertical processor node diagram with status coloring, connectors, and optional action menus."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
<RouteFlow
|
<RouteFlow
|
||||||
@@ -802,6 +807,10 @@ export function CompositesSection() {
|
|||||||
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
||||||
]}
|
]}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Change Log Level', onClick: () => {} },
|
||||||
|
{ label: 'Enable Tracing', onClick: () => {} },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|||||||
Reference in New Issue
Block a user