Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19303eefad | ||
|
|
5fe6321d30 | ||
|
|
90e3de2cdf | ||
|
|
499c86b680 |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -248,3 +248,40 @@
|
||||
.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); }
|
||||
|
||||
@@ -81,3 +81,80 @@ describe('RouteFlow', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,8 +25,15 @@ export interface NodeAction {
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface RouteFlowProps {
|
||||
export interface FlowSegment {
|
||||
label: string
|
||||
nodes: RouteNode[]
|
||||
variant?: 'default' | 'error' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
interface RouteFlowProps {
|
||||
nodes?: RouteNode[]
|
||||
flows?: FlowSegment[]
|
||||
onNodeClick?: (node: RouteNode, index: number) => void
|
||||
selectedIndex?: number
|
||||
actions?: NodeAction[]
|
||||
@@ -102,12 +109,110 @@ function renderActionTrigger(
|
||||
)
|
||||
}
|
||||
|
||||
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')
|
||||
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
|
||||
}, [])
|
||||
@@ -120,7 +225,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
|
||||
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} />
|
||||
@@ -176,7 +281,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
|
||||
<div className={styles.errorSection}>
|
||||
<div className={styles.errorLabel}>Error Handler</div>
|
||||
{errorHandlers.map((node, i) => {
|
||||
const errOriginalIndex = nodes.indexOf(node)
|
||||
const errOriginalIndex = allNodes.indexOf(node)
|
||||
return (
|
||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||
|
||||
@@ -34,7 +34,7 @@ export { Popover } from './Popover/Popover'
|
||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||
export type { RouteNode, NodeAction, NodeBadge } 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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -815,6 +815,37 @@ export function CompositesSection() {
|
||||
</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>
|
||||
|
||||
{/* 18. ShortcutsBar */}
|
||||
<DemoCard
|
||||
id="shortcutsbar"
|
||||
|
||||
Reference in New Issue
Block a user