Compare commits

...

6 Commits

Author SHA1 Message Date
hsiegeln
19303eefad feat: auto-slide time range forward when preset is active with auto-refresh
All checks were successful
Build & Publish / publish (push) Successful in 50s
Adds a 10s interval that recomputes the preset time range boundaries
so dashboards stay current without manual refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:54:24 +01:00
hsiegeln
5fe6321d30 feat: add multi-flow support to RouteFlow via flows prop
All checks were successful
Build & Publish / publish (push) Successful in 50s
Allow rendering multiple named flow segments (e.g., main route +
onException handler) within a single RouteFlow component. Each segment
gets a labeled section with variant-based color theming. Fully backward
compatible — existing nodes prop continues to work unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:10:27 +01:00
hsiegeln
90e3de2cdf feat: fix category counts and add matchContext for search highlights
All checks were successful
Build & Publish / publish (push) Successful in 58s
- Category tab counts now reflect query-filtered results, not total data
- Added matchContext field to SearchResult for server-side match snippets
- Renders <em>-tagged highlight text below meta in muted style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:26:55 +01:00
hsiegeln
499c86b680 feat: add onQueryChange prop and serverFiltered flag to CommandPalette
All checks were successful
Build & Publish / publish (push) Successful in 51s
Enables server-side exchange search from the command palette. Server-filtered
results skip client-side text matching since they matched on content not
visible in title/meta.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:51:16 +01:00
hsiegeln
63e16d2685 feat: add generic badge system to RouteFlow and ProcessorTimeline
All checks were successful
Build & Publish / publish (push) Successful in 49s
New NodeBadge interface with variant colors (info/success/warning/error)
and optional onClick. Replaces single-purpose bottleneckBadge with a
flexible badges array. Backwards compatible: isBottleneck still works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:06:55 +01:00
hsiegeln
19dccb8685 feat: add registerable actions to ProcessorTimeline and RouteFlow
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>
2026-03-24 19:49:07 +01:00
13 changed files with 734 additions and 47 deletions

View File

@@ -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 |

View File

@@ -277,6 +277,23 @@
overflow-y: auto;
}
/* Match context snippet */
.matchContext {
font-size: 11px;
color: var(--text-faint);
font-family: var(--font-mono);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchContext em {
font-style: normal;
color: var(--amber);
font-weight: 600;
}
/* Match highlight */
.mark {
background: none;

View File

@@ -12,6 +12,7 @@ interface CommandPaletteProps {
onSelect: (result: SearchResult) => void
data: SearchResult[]
onOpen?: () => void
onQueryChange?: (query: string) => void
}
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
@@ -60,7 +61,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
return <>{parts}</>
}
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
@@ -91,22 +92,17 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
}
}, [open])
// Filter results
const filtered = useMemo(() => {
// Stage 1: apply text query + scope filters (used for counts)
const queryFiltered = useMemo(() => {
let results = data
if (activeCategory !== 'all') {
results = results.filter((r) => r.category === activeCategory)
}
if (query.trim()) {
const q = query.toLowerCase()
results = results.filter(
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
)
}
// Apply scope filters
for (const sf of scopeFilters) {
results = results.filter((r) =>
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
@@ -114,7 +110,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
}
return results
}, [data, query, activeCategory, scopeFilters])
}, [data, query, scopeFilters])
// Stage 2: apply category filter (used for display)
const filtered = useMemo(() => {
if (activeCategory === 'all') return queryFiltered
return queryFiltered.filter((r) => r.category === activeCategory)
}, [queryFiltered, activeCategory])
// Group results by category
const grouped = useMemo(() => {
@@ -129,14 +131,14 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
// Flatten for keyboard nav
const flatResults = useMemo(() => filtered, [filtered])
// Counts per category
// Counts per category (from query-filtered, before category filter)
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = { all: data.length }
for (const r of data) {
const counts: Record<string, number> = { all: queryFiltered.length }
for (const r of queryFiltered) {
counts[r.category] = (counts[r.category] ?? 0) + 1
}
return counts
}, [data])
}, [queryFiltered])
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
@@ -208,6 +210,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onChange={(e) => {
setQuery(e.target.value)
setFocusedIdx(0)
onQueryChange?.(e.target.value)
}}
aria-label="Search"
/>
@@ -301,6 +304,12 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
<div className={styles.itemMeta}>
{highlightText(result.meta, query)}
</div>
{result.matchContext && (
<div
className={styles.matchContext}
dangerouslySetInnerHTML={{ __html: result.matchContext }}
/>
)}
</div>
{result.expandedContent && (
<button

View File

@@ -13,6 +13,10 @@ export interface SearchResult {
path?: string
expandedContent?: string
matchRanges?: [number, number][]
/** Skip client-side query filtering (result already matched server-side) */
serverFiltered?: boolean
/** Server-side match snippet with <em> tags around matched text */
matchContext?: string
}
export interface ScopeFilter {

View File

@@ -96,6 +96,58 @@
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);
}
/* Badges */
.badge {
font-family: var(--font-mono);
font-size: 7px;
font-weight: 600;
color: #fff;
border-radius: 6px;
padding: 0 4px;
text-transform: uppercase;
white-space: nowrap;
margin-left: 4px;
vertical-align: middle;
}
.badgeInfo { background: var(--running); }
.badgeSuccess { background: var(--success); }
.badgeWarning { background: var(--amber); }
.badgeError { background: var(--error); }
.empty {
color: var(--text-muted);
font-size: 12px;

View File

@@ -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')
})
})

View File

@@ -1,4 +1,7 @@
import type { ReactNode } from 'react'
import styles from './ProcessorTimeline.module.css'
import { Dropdown } from '../Dropdown/Dropdown'
import type { NodeBadge } from '../RouteFlow/RouteFlow'
export interface ProcessorStep {
name: string
@@ -6,6 +9,15 @@ export interface ProcessorStep {
durationMs: number
status: 'ok' | 'slow' | 'fail'
startMs: number
badges?: NodeBadge[]
}
export interface ProcessorAction {
label: string
icon?: ReactNode
onClick: () => void
disabled?: boolean
divider?: boolean
}
interface ProcessorTimelineProps {
@@ -13,6 +25,8 @@ interface ProcessorTimelineProps {
totalMs: number
onProcessorClick?: (processor: ProcessorStep, index: number) => void
selectedIndex?: number
actions?: ProcessorAction[]
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
className?: string
}
@@ -26,6 +40,8 @@ export function ProcessorTimeline({
totalMs,
onProcessorClick,
selectedIndex,
actions,
getActions,
className,
}: ProcessorTimelineProps) {
const safeTotal = totalMs || 1
@@ -70,6 +86,16 @@ export function ProcessorTimeline({
>
<div className={styles.name} title={proc.name}>
{proc.name}
{proc.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</div>
<div className={styles.barBg}>
<div
@@ -82,6 +108,30 @@ export function ProcessorTimeline({
</div>
</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>
)
})}

View File

@@ -188,17 +188,100 @@
outline-offset: 2px;
}
/* Bottleneck badge */
.bottleneckBadge {
/* 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);
}
/* Badges */
.badgeRow {
position: absolute;
top: -7px;
right: 8px;
display: flex;
gap: 3px;
pointer-events: auto;
}
.badge {
font-family: var(--font-mono);
font-size: 8px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
background: var(--error);
color: #fff;
border-radius: 8px;
padding: 1px 6px;
text-transform: uppercase;
white-space: nowrap;
letter-spacing: 0.3px;
}
.badgeInfo { background: var(--running); }
.badgeSuccess { background: var(--success); }
.badgeWarning { background: var(--amber); }
.badgeError { background: var(--error); }
/* Node wrapper (replaces inline style) */
.nodeWrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
/* Multi-flow sections */
.flowSection {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.flowSectionSeparated {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border);
}
.flowLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
padding-left: 2px;
width: 100%;
}
.flowLabelDefault { color: var(--text-muted); }
.flowLabelError { color: var(--error); }
.flowLabelWarning { color: var(--warning); }
.flowLabelInfo { color: var(--running); }

View File

@@ -0,0 +1,160 @@
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')
})
})
const multiFlows = [
{
label: 'Main Route',
nodes: [
{ name: 'timer:tick', type: 'from' as const, durationMs: 0, status: 'ok' as const },
{ name: 'Processor1', type: 'process' as const, durationMs: 8, status: 'ok' as const },
],
},
{
label: 'onException',
variant: 'error' as const,
nodes: [
{ name: 'LogHandler', type: 'process' as const, durationMs: 3, status: 'ok' as const },
{ name: 'dead-letter:errors', type: 'to' as const, durationMs: 8, status: 'fail' as const },
],
},
]
describe('RouteFlow (multi-flow)', () => {
it('renders all segment labels', () => {
render(<RouteFlow flows={multiFlows} />)
expect(screen.getByText('Main Route')).toBeInTheDocument()
expect(screen.getByText('onException')).toBeInTheDocument()
})
it('renders all nodes across segments', () => {
render(<RouteFlow flows={multiFlows} />)
expect(screen.getByText('timer:tick')).toBeInTheDocument()
expect(screen.getByText('Processor1')).toBeInTheDocument()
expect(screen.getByText('LogHandler')).toBeInTheDocument()
expect(screen.getByText('dead-letter:errors')).toBeInTheDocument()
})
it('uses global flat indexing for onNodeClick', async () => {
const onNodeClick = vi.fn()
const user = userEvent.setup()
render(<RouteFlow flows={multiFlows} onNodeClick={onNodeClick} />)
// Click the first node of the second flow (global index = 2)
await user.click(screen.getByText('LogHandler'))
expect(onNodeClick).toHaveBeenCalledWith(
expect.objectContaining({ name: 'LogHandler' }),
2,
)
})
it('selectedIndex highlights correct node across flows', () => {
const { container } = render(<RouteFlow flows={multiFlows} selectedIndex={3} />)
// Index 3 = dead-letter:errors (2nd node of 2nd flow)
const selectedNodes = container.querySelectorAll('[class*="nodeSelected"]')
expect(selectedNodes.length).toBe(1)
expect(selectedNodes[0]).toHaveTextContent('dead-letter:errors')
})
it('actions work in multi-flow mode', () => {
const { container } = render(
<RouteFlow
flows={multiFlows}
actions={[{ label: 'Test Action', onClick: () => {} }]}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(4)
})
it('flows takes precedence over nodes', () => {
render(
<RouteFlow
nodes={nodes}
flows={multiFlows}
/>,
)
// Should render flow content, not nodes content
expect(screen.getByText('Main Route')).toBeInTheDocument()
expect(screen.queryByText('jms:orders')).not.toBeInTheDocument()
})
})

View File

@@ -1,4 +1,12 @@
import type { ReactNode } from 'react'
import styles from './RouteFlow.module.css'
import { Dropdown } from '../Dropdown/Dropdown'
export interface NodeBadge {
label: string
variant?: 'info' | 'success' | 'warning' | 'error'
onClick?: () => void
}
export interface RouteNode {
name: string
@@ -6,12 +14,30 @@ export interface RouteNode {
durationMs: number
status: 'ok' | 'slow' | 'fail'
isBottleneck?: boolean
badges?: NodeBadge[]
}
export interface NodeAction {
label: string
icon?: ReactNode
onClick: () => void
disabled?: boolean
divider?: boolean
}
export interface FlowSegment {
label: string
nodes: RouteNode[]
variant?: 'default' | 'error' | 'warning' | 'info'
}
interface RouteFlowProps {
nodes: RouteNode[]
nodes?: RouteNode[]
flows?: FlowSegment[]
onNodeClick?: (node: RouteNode, index: number) => void
selectedIndex?: number
actions?: NodeAction[]
getActions?: (node: RouteNode, index: number) => NodeAction[]
className?: string
}
@@ -52,12 +78,141 @@ function nodeStatusClass(node: RouteNode): string {
return styles.nodeHealthy
}
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
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>
)
}
const FLOW_LABEL_CLASSES: Record<string, string> = {
'default': styles.flowLabelDefault,
'error': styles.flowLabelError,
'warning': styles.flowLabelWarning,
'info': styles.flowLabelInfo,
}
function renderNodeChain(
nodes: RouteNode[],
globalIndexOffset: number,
onNodeClick?: RouteFlowProps['onNodeClick'],
selectedIndex?: number,
actions?: NodeAction[],
getActions?: (node: RouteNode, index: number) => NodeAction[],
) {
const isClickable = !!onNodeClick
return nodes.map((node, i) => {
const globalIndex = globalIndexOffset + i
const isSelected = selectedIndex === globalIndex
return (
<div key={i} className={styles.nodeWrapper}>
{i > 0 && (
<div className={styles.connector}>
<div className={styles.connectorLine} />
<div className={styles.connectorArrow} />
</div>
)}
<div
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
onClick={() => onNodeClick?.(node, globalIndex)}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onKeyDown={(e) => {
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onNodeClick?.(node, globalIndex)
}
}}
>
{(node.isBottleneck || node.badges?.length) ? (
<span className={styles.badgeRow}>
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
{node.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'}
</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, globalIndex, isSelected, actions, getActions)}
</div>
</div>
)
})
}
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
// Multi-flow mode
if (flows && flows.length > 0) {
let globalOffset = 0
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{flows.map((flow, fi) => {
const sectionOffset = globalOffset
globalOffset += flow.nodes.length
const variant = flow.variant ?? 'default'
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
return (
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
</div>
)
})}
</div>
)
}
// Legacy mode (single nodes array with automatic error-handler separation)
const allNodes = nodes ?? []
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
// Map from mainNodes index back to original nodes index
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
if (n.type !== 'error-handler') acc.push(idx)
return acc
}, [])
@@ -70,7 +225,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
const isClickable = !!onNodeClick
return (
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div key={i} className={styles.nodeWrapper}>
{i > 0 && (
<div className={styles.connector}>
<div className={styles.connectorLine} />
@@ -89,7 +244,21 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
}
}}
>
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
{(node.isBottleneck || node.badges?.length) ? (
<span className={styles.badgeRow}>
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
{node.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'}
</div>
@@ -102,6 +271,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
</div>
</div>
)
@@ -110,22 +280,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
{errorHandlers.length > 0 && (
<div className={styles.errorSection}>
<div className={styles.errorLabel}>Error Handler</div>
{errorHandlers.map((node, i) => (
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
{TYPE_ICONS['error-handler']}
</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)}
{errorHandlers.map((node, i) => {
const errOriginalIndex = allNodes.indexOf(node)
return (
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
{TYPE_ICONS['error-handler']}
</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>

View File

@@ -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, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { SplitPane } from './SplitPane/SplitPane'

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
import { computePresetRange } from '../utils/timePresets'
export interface TimeRange {
@@ -66,6 +66,16 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
}, [])
// Keep the time range sliding forward when a preset is active and live
useEffect(() => {
if (!autoRefresh || !timeRange.preset) return
const id = setInterval(() => {
const { start, end } = computePresetRange(timeRange.preset!)
setTimeRangeState({ start, end, preset: timeRange.preset })
}, 10_000)
return () => clearInterval(id)
}, [autoRefresh, timeRange.preset])
const isInTimeRange = useCallback(
(timestamp: Date) => {
if (timeRange.preset) {

View File

@@ -769,7 +769,7 @@ export function CompositesSection() {
<DemoCard
id="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%' }}>
<ProcessorTimeline
@@ -780,6 +780,11 @@ export function CompositesSection() {
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
{ 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>
</DemoCard>
@@ -788,7 +793,7 @@ export function CompositesSection() {
<DemoCard
id="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 }}>
<RouteFlow
@@ -802,6 +807,41 @@ export function CompositesSection() {
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
]}
actions={[
{ label: 'Change Log Level', onClick: () => {} },
{ label: 'Enable Tracing', onClick: () => {} },
]}
/>
</div>
</DemoCard>
{/* 17c. RouteFlow (Multi-Flow) */}
<DemoCard
id="routeflow-multi"
title="RouteFlow (Multi-Flow)"
description="Multiple flow segments with labels, showing a main route alongside an exception handler."
>
<div style={{ width: '100%', maxWidth: 360 }}>
<RouteFlow
flows={[
{
label: 'Main Route',
nodes: [
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow' },
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
],
},
{
label: 'onException(IOException)',
variant: 'error',
nodes: [
{ name: 'log:error-logger', type: 'process', durationMs: 2, status: 'ok' },
{ name: 'dead-letter:failed-orders', type: 'to', durationMs: 14, status: 'fail' },
],
},
]}
/>
</div>
</DemoCard>