Compare commits

...

9 Commits

Author SHA1 Message Date
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
hsiegeln
4b873194c9 fix(ci): add --allow-same-version to npm version in publish workflow
All checks were successful
Build & Publish / publish (push) Successful in 51s
The tag push job fails with "Version not changed" when package.json
already has the correct version from the bump commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:05:08 +01:00
hsiegeln
5f1b039056 chore: bump version to 0.1.6
Some checks failed
Build & Publish / publish (push) Failing after 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:01:53 +01:00
hsiegeln
095abe1751 feat: self-portaling DetailPanel via AppShell portal target
Some checks failed
Build & Publish / publish (push) Failing after 50s
DetailPanel now uses createPortal to render itself into
#cameleer-detail-panel-root, a div that AppShell places as a
direct sibling of .main in the top-level flex row. This means
pages can render <DetailPanel> anywhere in their JSX and it
automatically appears at the correct layout position.

AppShell's detail prop is deprecated and ignored — the portal
handles positioning automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:58:06 +01:00
hsiegeln
e8859e53ce chore: sync package-lock.json
All checks were successful
Build & Publish / publish (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:12:21 +01:00
hsiegeln
021f6c7811 chore: bump version to 0.1.3
Some checks failed
Build & Publish / publish (push) Failing after 6s
2026-03-24 18:04:51 +01:00
hsiegeln
c18ba7d085 fix: exclude e2e tests from CI vitest run
Some checks failed
Build & Publish / publish (push) Failing after 7s
Playwright e2e tests need a browser and can't run in the CI container.
Exclude e2e/ directory from vitest so only unit tests run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:01:26 +01:00
hsiegeln
795ffef9dc feat: add auto-refresh toggle to TopBar and GlobalFilterProvider
Some checks failed
Build & Publish / publish (push) Failing after 5s
Add autoRefresh/setAutoRefresh to GlobalFilterContext, persisted in
localStorage. TopBar shows a LIVE/PAUSED toggle button with pulsing
dot indicator. Consumers can use useGlobalFilters().autoRefresh to
conditionally enable/disable polling intervals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:58:20 +01:00
hsiegeln
039f2fa5fe feat: add onSortChange callback to DataTable for server-side sorting
All checks were successful
Build & Publish / publish (push) Successful in 1m21s
When onSortChange is provided, DataTable operates in controlled mode:
it renders sort indicators and fires the callback on header clicks,
but skips client-side sorting — the caller provides pre-sorted data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:51:22 +01:00
19 changed files with 539 additions and 39 deletions

View File

@@ -17,7 +17,7 @@ jobs:
run: npm ci run: npm ci
- name: Run tests - name: Run tests
run: npx vitest run run: npx vitest run --exclude 'e2e/**'
- name: Build library - name: Build library
run: npm run build:lib run: npm run build:lib
@@ -28,7 +28,7 @@ jobs:
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) refs/tags/v*)
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version npm version "$VERSION" --no-git-tag-version --allow-same-version
TAG="latest" TAG="latest"
;; ;;
*) *)

View File

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

77
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{ {
"name": "cameleer3", "name": "@cameleer/design-system",
"version": "0.0.0", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cameleer3", "name": "@cameleer/design-system",
"version": "0.0.0", "version": "0.1.6",
"dependencies": { "dependencies": {
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -24,6 +25,11 @@
"vite": "^6.0.0", "vite": "^6.0.0",
"vite-plugin-dts": "^4.5.4", "vite-plugin-dts": "^4.5.4",
"vitest": "^3.0.0" "vitest": "^3.0.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@@ -925,6 +931,22 @@
"resolve": "~1.22.2" "resolve": "~1.22.2"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2874,6 +2896,53 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.0", "version": "0.1.6",
"type": "module", "type": "module",
"main": "./dist/index.es.js", "main": "./dist/index.es.js",
"module": "./dist/index.es.js", "module": "./dist/index.es.js",
@@ -12,8 +12,12 @@
}, },
"./style.css": "./dist/style.css" "./style.css": "./dist/style.css"
}, },
"files": ["dist"], "files": [
"sideEffects": ["*.css"], "dist"
],
"sideEffects": [
"*.css"
],
"publishConfig": { "publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/" "registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
}, },
@@ -27,7 +31,8 @@
"build:lib": "vite build --config vite.lib.config.ts", "build:lib": "vite build --config vite.lib.config.ts",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest" "test": "vitest",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"react": "^19.0.0", "react": "^19.0.0",
@@ -40,6 +45,7 @@
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",

View File

@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
rowAccent, rowAccent,
expandedContent, expandedContent,
flush = false, flush = false,
onSortChange,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null) const [sortKey, setSortKey] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc') const [sortDir, setSortDir] = useState<SortDir>('asc')
@@ -31,14 +32,16 @@ export function DataTable<T extends { id: string }>({
const [pageSize, setPageSize] = useState(initialPageSize) const [pageSize, setPageSize] = useState(initialPageSize)
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
// When onSortChange is provided (controlled mode), skip client-side sorting
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (onSortChange) return data
if (!sortKey) return data if (!sortKey) return data
return [...data].sort((a, b) => { return [...data].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey] const av = (a as Record<string, unknown>)[sortKey]
const bv = (b as Record<string, unknown>)[sortKey] const bv = (b as Record<string, unknown>)[sortKey]
return compareValues(av, bv, sortDir) return compareValues(av, bv, sortDir)
}) })
}, [data, sortKey, sortDir]) }, [data, sortKey, sortDir, onSortChange])
const totalRows = sorted.length const totalRows = sorted.length
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize)) const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
@@ -52,13 +55,17 @@ export function DataTable<T extends { id: string }>({
function handleHeaderClick(col: Column<T>) { function handleHeaderClick(col: Column<T>) {
if (!sortable && !col.sortable) return if (!sortable && !col.sortable) return
let newDir: SortDir
if (sortKey === col.key) { if (sortKey === col.key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) newDir = sortDir === 'asc' ? 'desc' : 'asc'
setSortDir(newDir)
} else { } else {
newDir = 'asc'
setSortKey(col.key) setSortKey(col.key)
setSortDir('asc') setSortDir(newDir)
} }
setPage(1) setPage(1)
onSortChange?.(col.key, newDir)
} }
function handleRowClick(row: T) { function handleRowClick(row: T) {

View File

@@ -20,4 +20,8 @@ export interface DataTableProps<T extends { id: string }> {
expandedContent?: (row: T) => ReactNode | null expandedContent?: (row: T) => ReactNode | null
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */ /** Strip border, radius, and shadow so the table sits flush inside a parent container. */
flush?: boolean flush?: boolean
/** Controlled sort: called when the user clicks a sortable column header.
* When provided, the component skips client-side sorting — the caller is
* responsible for providing `data` in the desired order. */
onSortChange?: (key: string, dir: 'asc' | 'desc') => void
} }

View File

@@ -1,4 +1,5 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import styles from './DetailPanel.module.css' import styles from './DetailPanel.module.css'
interface Tab { interface Tab {
@@ -22,7 +23,7 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
const activeContent = tabs?.find((t) => t.value === activeTab)?.content const activeContent = tabs?.find((t) => t.value === activeTab)?.content
return ( const panel = (
<aside <aside
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`} className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
aria-hidden={!open} aria-hidden={!open}
@@ -65,4 +66,8 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
)} )}
</aside> </aside>
) )
// Portal to AppShell level if target exists, otherwise render in place
const portalTarget = document.getElementById('cameleer-detail-panel-root')
return portalTarget ? createPortal(panel, portalTarget) : panel
} }

View File

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

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,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>
) )
})} })}

View File

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

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

View File

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

View File

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

View File

@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
interface AppShellProps { interface AppShellProps {
sidebar: ReactNode sidebar: ReactNode
children: ReactNode children: ReactNode
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
detail?: ReactNode detail?: ReactNode
} }
export function AppShell({ sidebar, children, detail }: AppShellProps) { export function AppShell({ sidebar, children }: AppShellProps) {
return ( return (
<div className={styles.app}> <div className={styles.app}>
{sidebar} {sidebar}
<div className={styles.main}> <div className={styles.main}>
{children} {children}
</div> </div>
{detail} <div id="cameleer-detail-panel-root" />
</div> </div>
) )
} }

View File

@@ -81,6 +81,56 @@
flex-shrink: 0; flex-shrink: 0;
} }
.liveToggle {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
height: 30px;
}
.liveToggle:hover {
border-color: var(--text-faint);
}
.liveToggleActive {
color: var(--success);
border-color: var(--success-border);
background: var(--success-bg);
}
.liveToggleActive:hover {
border-color: var(--success);
}
.liveDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.liveToggleActive .liveDot {
background: var(--success);
animation: livePulse 2s ease-in-out infinite;
}
@keyframes livePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.themeToggle { .themeToggle {
background: none; background: none;
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@@ -84,8 +84,18 @@ export function TopBar({
onChange={globalFilters.setTimeRange} onChange={globalFilters.setTimeRange}
/> />
{/* Right: theme toggle, env badge, user */} {/* Right: auto-refresh toggle, theme toggle, env badge, user */}
<div className={styles.right}> <div className={styles.right}>
<button
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
type="button"
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
</button>
<button <button
className={styles.themeToggle} className={styles.themeToggle}
onClick={toggleTheme} onClick={toggleTheme}

View File

@@ -16,6 +16,8 @@ interface GlobalFilterContextValue {
toggleStatus: (status: ExchangeStatus) => void toggleStatus: (status: ExchangeStatus) => void
clearStatusFilters: () => void clearStatusFilters: () => void
isInTimeRange: (timestamp: Date) => boolean isInTimeRange: (timestamp: Date) => boolean
autoRefresh: boolean
setAutoRefresh: (enabled: boolean) => void
} }
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null) const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
@@ -27,9 +29,17 @@ function getDefaultTimeRange(): TimeRange {
return { start, end, preset: DEFAULT_PRESET } return { start, end, preset: DEFAULT_PRESET }
} }
function getInitialAutoRefresh(): boolean {
try {
const stored = localStorage.getItem('cameleer:auto-refresh')
return stored === null ? true : stored === 'true'
} catch { return true }
}
export function GlobalFilterProvider({ children }: { children: ReactNode }) { export function GlobalFilterProvider({ children }: { children: ReactNode }) {
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange) const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set()) const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
const setTimeRange = useCallback((range: TimeRange) => { const setTimeRange = useCallback((range: TimeRange) => {
setTimeRangeState(range) setTimeRangeState(range)
@@ -51,6 +61,11 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
setStatusFilters(new Set()) setStatusFilters(new Set())
}, []) }, [])
const setAutoRefresh = useCallback((enabled: boolean) => {
setAutoRefreshState(enabled)
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
}, [])
const isInTimeRange = useCallback( const isInTimeRange = useCallback(
(timestamp: Date) => { (timestamp: Date) => {
if (timeRange.preset) { if (timeRange.preset) {
@@ -65,7 +80,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
return ( return (
<GlobalFilterContext.Provider <GlobalFilterContext.Provider
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }} value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
> >
{children} {children}
</GlobalFilterContext.Provider> </GlobalFilterContext.Provider>

View File

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