Compare commits

...

17 Commits

Author SHA1 Message Date
hsiegeln
a12b374fb2 feat: add onSubmit prop to CommandPalette for full-text search
All checks were successful
Build & Publish / publish (push) Successful in 58s
When Enter is pressed without explicit arrow/mouse navigation,
fires onSubmit with the raw query instead of selecting the
focused result. Enables using the palette as a search filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:30:36 +01:00
hsiegeln
433d582da6 feat: migrate all icons to Lucide React
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Replace unicode characters, emoji, and inline SVGs with lucide-react
components across the entire design system and page layer. Update
tests to assert on SVG elements instead of text content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:25:43 +01:00
hsiegeln
2ffc268b44 feat: add attribute search category to CommandPalette
All checks were successful
Build & Publish / publish (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:03:57 +01:00
hsiegeln
99ae66315b feat: add trace log level to LogViewer
All checks were successful
Build & Publish / publish (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:04:50 +01:00
hsiegeln
26de5ec58f feat: add click-to-close backdrop behind DetailPanel
All checks were successful
Build & Publish / publish (push) Successful in 58s
Adds a subtle semi-transparent backdrop (rgba(0,0,0,0.15)) behind the
overlay panel. Clicking the backdrop closes the panel. Fades in with
the panel animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:57:34 +01:00
hsiegeln
d26dc6a8a5 fix: make DetailPanel overlay instead of taking flex space
All checks were successful
Build & Publish / publish (push) Successful in 1m1s
DetailPanel now uses position: fixed to overlay the content area rather
than participating in the AppShell flex row. This prevents the TopBar
from being compressed when the panel opens. Added box-shadow for depth
separation and z-index: 100 for stacking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:55:02 +01:00
hsiegeln
c0b1cbdc5b fix: remove inset background from LogViewer container
All checks were successful
Build & Publish / publish (push) Successful in 1m30s
LogViewer was using --bg-inset which created a visual mismatch with the
EventFeed timeline panel. Now inherits the parent card's background
(--bg-surface) for consistent appearance in side-by-side layouts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:24:35 +01:00
hsiegeln
d101d883a9 fix: auto-scroll to top for EventFeed and LogViewer
All checks were successful
Build & Publish / publish (push) Successful in 52s
Newest entries appear at the top in descending sort, so auto-scroll
should snap to top instead of bottom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:45:30 +01:00
hsiegeln
2a020c1e15 feat: add BreadcrumbProvider and e2e test suite
All checks were successful
Build & Publish / publish (push) Successful in 53s
Add BreadcrumbProvider context so pages can override TopBar breadcrumbs
dynamically. Add Playwright e2e tests for dashboard, agents, routes,
exchanges, and admin pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:33:55 +01:00
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
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
50 changed files with 1514 additions and 239 deletions

View File

@@ -15,7 +15,21 @@
"Bash(echo \"EXIT:$?\")",
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
"Bash(echo \"EXIT_CODE=$?\")",
"Bash(echo \"EXIT=$?\")"
"Bash(echo \"EXIT=$?\")",
"mcp__gitea__actions_config_read",
"mcp__gitea__search_repos",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")",
"Bash(npx vite:*)",
"Bash(cd:*)",
"mcp__gitea__actions_run_read",
"mcp__gitea__get_file_contents",
"WebFetch(domain:ui.shadcn.com)",
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")",
"Bash(npm test:*)",
"Bash(grep:*)",
"Bash(xargs cat:*)"
]
}
}

View File

@@ -28,7 +28,7 @@ jobs:
case "$GITHUB_REF" in
refs/tags/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"
;;
*)

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@ node_modules/
dist/
.superpowers/
.worktrees/
test-results/
screenshots/
.playwright-mcp/

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 |

165
e2e/admin.spec.ts Normal file
View File

@@ -0,0 +1,165 @@
import { test, expect } from '@playwright/test'
test.describe('Admin - User Management (/admin/rbac)', () => {
test('renders admin tabs and user table', async ({ page }) => {
await page.goto('/admin/rbac')
// Admin navigation tabs
await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible()
// User Management sub-tabs
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible()
// TopBar breadcrumb
await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible()
})
test('switching between Users, Groups, and Roles tabs', async ({ page }) => {
await page.goto('/admin/rbac')
// Default tab is Users
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
// Switch to Groups tab
await page.getByRole('tab', { name: 'Groups' }).click()
await page.waitForTimeout(300)
// Switch to Roles tab
await page.getByRole('tab', { name: 'Roles' }).click()
await page.waitForTimeout(300)
// Switch back to Users
await page.getByRole('tab', { name: 'Users' }).click()
})
test('navigating between admin sections via tabs', async ({ page }) => {
await page.goto('/admin/rbac')
// Click Audit Log tab
await page.getByRole('tab', { name: 'Audit Log' }).click()
await expect(page).toHaveURL(/\/admin\/audit/)
// Click OIDC tab
await page.getByRole('tab', { name: 'OIDC' }).click()
await expect(page).toHaveURL(/\/admin\/oidc/)
// Back to User Management
await page.getByRole('tab', { name: 'User Management' }).click()
await expect(page).toHaveURL(/\/admin\/rbac/)
})
})
test.describe('Admin - Audit Log (/admin/audit)', () => {
test('renders audit table with filters', async ({ page }) => {
await page.goto('/admin/audit')
// Table headers
await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible()
// Table has data
const rows = page.locator('table tbody tr')
expect(await rows.count()).toBeGreaterThan(0)
// Filter inputs exist
await expect(page.getByPlaceholder('Filter by user...')).toBeVisible()
await expect(page.getByPlaceholder('Search action or target...')).toBeVisible()
})
test('filtering audit events by search', async ({ page }) => {
await page.goto('/admin/audit')
const searchInput = page.getByPlaceholder('Search action or target...')
await searchInput.fill('deploy')
// Table should update
await page.waitForTimeout(300)
const rows = page.locator('table tbody tr')
const count = await rows.count()
expect(count).toBeGreaterThanOrEqual(0)
})
})
test.describe('Admin - OIDC Config (/admin/oidc)', () => {
test('renders OIDC form with all fields', async ({ page }) => {
await page.goto('/admin/oidc')
// Section headers
await expect(page.getByText('Behavior')).toBeVisible()
await expect(page.getByText('Provider Settings')).toBeVisible()
await expect(page.getByText('Claim Mapping')).toBeVisible()
await expect(page.getByText('Default Roles')).toBeVisible()
await expect(page.getByText('Danger Zone')).toBeVisible()
// Form fields by id
await expect(page.locator('#issuer')).toBeVisible()
await expect(page.locator('#client-id')).toBeVisible()
await expect(page.locator('#client-secret')).toBeVisible()
await expect(page.locator('#roles-claim')).toBeVisible()
await expect(page.locator('#name-claim')).toBeVisible()
// Buttons
await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible()
// Default roles tags
await expect(page.getByText('USER').first()).toBeVisible()
await expect(page.getByText('VIEWER').first()).toBeVisible()
})
test('toggling Enabled switch', async ({ page }) => {
await page.goto('/admin/oidc')
// The Toggle's checkbox is visually hidden — click the label wrapper instead
const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' })
await enabledLabel.click()
// Should not crash; label still visible
await expect(enabledLabel).toBeVisible()
})
test('adding and removing a role tag', async ({ page }) => {
await page.goto('/admin/oidc')
// Add a new role
const roleInput = page.getByPlaceholder('Add role...')
await roleInput.fill('EDITOR')
// Use the Add button next to the input (scoped to same row)
await roleInput.press('Enter')
// New role tag should appear
await expect(page.getByText('EDITOR')).toBeVisible()
// Remove it via aria-label on the tag's remove button
await page.getByRole('button', { name: 'Remove EDITOR' }).click()
// EDITOR tag should be gone
await expect(page.getByText('EDITOR')).not.toBeVisible()
})
test('Save button shows success toast', async ({ page }) => {
await page.goto('/admin/oidc')
await page.getByRole('button', { name: 'Save' }).click()
// Toast notification
await expect(page.getByText('Settings saved')).toBeVisible()
})
test('Delete button shows confirmation dialog', async ({ page }) => {
await page.goto('/admin/oidc')
await page.getByRole('button', { name: /Delete OIDC/i }).click()
// Confirmation dialog should appear
await expect(page.getByText('Delete OIDC configuration?')).toBeVisible()
})
})

80
e2e/agents.spec.ts Normal file
View File

@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test'
test.describe('Agent Health (/agents)', () => {
test('renders stat cards and group cards', async ({ page }) => {
await page.goto('/agents')
// Stat strip
await expect(page.getByText('Total Agents')).toBeVisible()
await expect(page.getByText('Total TPS')).toBeVisible()
// Group cards for each application
await expect(page.getByText('order-service').first()).toBeVisible()
await expect(page.getByText('payment-svc').first()).toBeVisible()
await expect(page.getByText('notification-hub').first()).toBeVisible()
// Instance tables have data
const instanceRows = page.locator('table tbody tr')
expect(await instanceRows.count()).toBeGreaterThan(0)
// Instance table headers
await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible()
// Timeline section
await expect(page.getByText('Timeline').first()).toBeVisible()
})
test('clicking an instance row opens the detail panel', async ({ page }) => {
await page.goto('/agents')
// Click first instance row
const instanceRow = page.locator('table tbody tr').first()
await instanceRow.click()
// Detail panel opens — look for detail-specific labels
await expect(page.getByText('Version').first()).toBeVisible()
await expect(page.getByText('Throughput').first()).toBeVisible()
})
test('detail panel has Performance tab with charts', async ({ page }) => {
await page.goto('/agents')
// Click an instance to open detail panel
const instanceRow = page.locator('table tbody tr').first()
await instanceRow.click()
// Wait for panel to open
await expect(page.getByText('Version').first()).toBeVisible()
// DetailPanel tabs are plain buttons (not role="tab")
// Switch to Performance tab
const perfTab = page.getByRole('button', { name: 'Performance' })
await perfTab.click()
// Performance charts should render
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible()
})
test('app-scoped agents view', async ({ page }) => {
await page.goto('/agents/order-service')
// Breadcrumb/scope shows app
await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible()
// Only order-service agents should show
await expect(page.getByText('ord-1').first()).toBeVisible()
await expect(page.getByText('ord-2').first()).toBeVisible()
await expect(page.getByText('ord-3').first()).toBeVisible()
})
test('dead agent shows alert banner', async ({ page }) => {
await page.goto('/agents')
// notification-hub has a dead instance, should show alert
await expect(page.getByText('Single point of failure')).toBeVisible()
})
})

90
e2e/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test'
/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */
async function widenTimeRange(page: import('@playwright/test').Page) {
await page.getByRole('tab', { name: '7d' }).click()
}
test.describe('Dashboard (/apps)', () => {
test('renders KPI stat cards and exchange table', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// KPI health strip renders
await expect(page.getByText('Recent Exchanges')).toBeVisible()
// Table headers
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible()
// Table has data rows
const rows = page.locator('table tbody tr')
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThan(0)
// Sidebar renders with app names
await expect(page.getByText('order-service').first()).toBeVisible()
await expect(page.getByText('payment-svc').first()).toBeVisible()
// TopBar renders
await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible()
await expect(page.getByText('PRODUCTION')).toBeVisible()
// Shortcuts bar
await expect(page.getByText('Ctrl+K').first()).toBeVisible()
})
test('clicking a table row opens the detail panel', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// Click the first data row
const firstRow = page.locator('table tbody tr').first()
await expect(firstRow).toBeVisible()
await firstRow.click()
// Detail panel should open — look for "Open full details" link
await expect(page.getByText('Open full details')).toBeVisible()
// Overview section
await expect(page.getByText('Correlation').first()).toBeVisible()
})
test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => {
await page.goto('/apps/order-service')
// Breadcrumb shows app scope
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
// Table should still render
await expect(page.getByText('Recent Exchanges')).toBeVisible()
})
test('sidebar navigation works', async ({ page }) => {
await page.goto('/apps')
// Click on an app in the sidebar
const sidebarApp = page.getByText('order-service').first()
await sidebarApp.click()
// URL should change to the app scope
await expect(page).toHaveURL(/\/apps\/order-service/)
})
test('inspect button navigates to exchange detail', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// Wait for table rows to appear
const firstRow = page.locator('table tbody tr').first()
await expect(firstRow).toBeVisible()
// Click the inspect button (↗) on first row
const inspectBtn = firstRow.locator('button[title="Inspect exchange"]')
await inspectBtn.click()
// Should navigate to exchange detail page
await expect(page).toHaveURL(/\/exchanges\//)
})
})

60
e2e/exchanges.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test'
test.describe('Exchange Detail (/exchanges/:id)', () => {
test('renders exchange header, timeline, and message panels', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Exchange header — use the one NOT in the breadcrumb
await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible()
// Header stats
await expect(page.getByText('Duration').first()).toBeVisible()
await expect(page.getByText('Processors').first()).toBeVisible()
// Processor Timeline section
await expect(page.getByText('Processor Timeline').first()).toBeVisible()
// Timeline/Flow toggle buttons
await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible()
// Message IN panel
await expect(page.getByText('Message IN')).toBeVisible()
await expect(page.getByText('Headers').first()).toBeVisible()
await expect(page.getByText('Body').first()).toBeVisible()
})
test('switching between Timeline and Flow view', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Default view is timeline (gantt)
const timelineBtn = page.getByRole('button', { name: 'Timeline' })
const flowBtn = page.getByRole('button', { name: 'Flow' })
// Switch to Flow view
await flowBtn.click()
// Flow view should render (RouteFlow component)
await expect(flowBtn).toHaveClass(/active|Active/)
// Switch back to Timeline
await timelineBtn.click()
await expect(timelineBtn).toHaveClass(/active|Active/)
})
test('not-found exchange shows warning', async ({ page }) => {
await page.goto('/exchanges/nonexistent-id')
await expect(page.getByText('not found', { exact: false })).toBeVisible()
})
test('breadcrumb navigation works', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Click Applications breadcrumb to go back
const appsBreadcrumb = page.getByRole('link', { name: 'Applications' })
await appsBreadcrumb.click()
await expect(page).toHaveURL(/\/apps/)
})
})

63
e2e/routes.spec.ts Normal file
View File

@@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test'
test.describe('Routes (/routes)', () => {
test('renders KPI cards, route table, and charts', async ({ page }) => {
await page.goto('/routes')
// KPI cards
await expect(page.getByText('Total Throughput')).toBeVisible()
await expect(page.getByText('System Error Rate')).toBeVisible()
await expect(page.getByText('Latency Percentiles')).toBeVisible()
await expect(page.getByText('Active Routes')).toBeVisible()
await expect(page.getByText('In-Flight Exchanges')).toBeVisible()
// Route performance table
await expect(page.getByText('Per-Route Performance')).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible()
const rows = page.locator('table tbody tr')
expect(await rows.count()).toBeGreaterThan(0)
// Charts render
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
await expect(page.getByText('Latency (ms)')).toBeVisible()
await expect(page.getByText('Errors by Route')).toBeVisible()
await expect(page.getByText('Message Volume (msg/min)')).toBeVisible()
// Auto-refresh indicator
await expect(page.getByText('Auto-refresh: 30s')).toBeVisible()
})
test('clicking a route row navigates to route detail', async ({ page }) => {
await page.goto('/routes')
// Click first route row
const firstRow = page.locator('table tbody tr').first()
await firstRow.click()
// Should navigate to route detail
await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/)
// Route detail view: processor performance table
await expect(page.getByText('Processor Performance')).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible()
// Route Flow diagram
await expect(page.getByText('Route Flow')).toBeVisible()
})
test('app-scoped routes view filters data', async ({ page }) => {
await page.goto('/routes/order-service')
// Breadcrumb shows scope
await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible()
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
// Table still renders
await expect(page.getByText('Per-Route Performance')).toBeVisible()
})
})

14
package-lock.json generated
View File

@@ -1,13 +1,14 @@
{
"name": "@cameleer/design-system",
"version": "0.1.3",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cameleer/design-system",
"version": "0.1.3",
"version": "0.1.6",
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
@@ -2714,6 +2715,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@cameleer/design-system",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
@@ -35,6 +35,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"

21
playwright.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: 0,
use: {
baseURL: 'http://localhost:5173',
headless: true,
viewport: { width: 1440, height: 900 },
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
timeout: 15_000,
},
})

View File

@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
it('renders danger variant icon', () => {
render(<AlertDialog {...defaultProps} variant="danger" />)
// Icon area should be present (aria-hidden)
expect(screen.getByText('✕')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders warning variant icon', () => {
render(<AlertDialog {...defaultProps} variant="warning" />)
expect(screen.getByText('⚠')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders info variant icon', () => {
render(<AlertDialog {...defaultProps} variant="info" />)
expect(screen.getByText('')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import React, { useEffect, useRef } from 'react'
import { XCircle, AlertTriangle, Info } from 'lucide-react'
import { Modal } from '../Modal/Modal'
import { Button } from '../../primitives/Button/Button'
import styles from './AlertDialog.module.css'
@@ -16,10 +17,10 @@ interface AlertDialogProps {
className?: string
}
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
danger: '✕',
warning: '⚠',
info: '',
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
danger: <XCircle size={20} />,
warning: <AlertTriangle size={20} />,
info: <Info size={20} />,
}
export function AlertDialog({

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

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
import styles from './CommandPalette.module.css'
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
@@ -12,12 +13,17 @@ interface CommandPaletteProps {
onSelect: (result: SearchResult) => void
data: SearchResult[]
onOpen?: () => void
onQueryChange?: (query: string) => void
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
* Useful for applying the query as a full-text search filter. */
onSubmit?: (query: string) => void
}
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
all: 'All',
application: 'Applications',
exchange: 'Exchanges',
attribute: 'Attributes',
route: 'Routes',
agent: 'Agents',
}
@@ -26,6 +32,7 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all',
'application',
'exchange',
'attribute',
'route',
'agent',
]
@@ -60,12 +67,13 @@ 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, onSubmit }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
const [focusedIdx, setFocusedIdx] = useState(0)
const [expandedId, setExpandedId] = useState<string | null>(null)
const userNavigated = useRef(false)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
@@ -88,25 +96,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
setQuery('')
setFocusedIdx(0)
setExpandedId(null)
userNavigated.current = false
}
}, [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 +118,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 +139,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) {
@@ -145,15 +155,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
break
case 'ArrowDown':
e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
break
case 'ArrowUp':
e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
if (flatResults[focusedIdx]) {
if (!userNavigated.current && onSubmit && query.trim()) {
onSubmit(query.trim())
onClose()
} else if (flatResults[focusedIdx]) {
onSelect(flatResults[focusedIdx])
onClose()
}
@@ -185,7 +200,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
>
{/* Search input area */}
<div className={styles.searchArea}>
<span className={styles.searchIcon} aria-hidden="true"></span>
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
{scopeFilters.map((sf, i) => (
<span key={i} className={styles.scopeTag}>
<span className={styles.scopeField}>{sf.field}:</span>
@@ -195,7 +210,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onClick={() => removeScopeFilter(i)}
aria-label={`Remove filter ${sf.field}:${sf.value}`}
>
×
<X size={10} />
</button>
</span>
))}
@@ -208,6 +223,8 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onChange={(e) => {
setQuery(e.target.value)
setFocusedIdx(0)
userNavigated.current = false
onQueryChange?.(e.target.value)
}}
aria-label="Search"
/>
@@ -276,7 +293,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onSelect(result)
onClose()
}}
onMouseEnter={() => setFocusedIdx(flatIdx)}
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
>
<div className={styles.itemMain}>
{result.icon && (
@@ -301,6 +318,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
@@ -312,7 +335,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
aria-expanded={isExpanded}
aria-label="Toggle detail"
>
{isExpanded ? '▲' : '▼'}
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)}
</div>
@@ -341,7 +364,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
</div>
<div className={styles.shortcut}>
<KeyboardHint keys="Enter" />
<span>Open</span>
<span>Search</span>
</div>
<div className={styles.shortcut}>
<KeyboardHint keys="Esc" />

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent'
export interface SearchResult {
id: string
@@ -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

@@ -1,4 +1,21 @@
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.15);
z-index: 99;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 0;
overflow: hidden;
transition: width 0.25s ease, opacity 0.2s ease;
@@ -7,13 +24,15 @@
display: flex;
flex-direction: column;
background: var(--bg-surface);
flex-shrink: 0;
z-index: 100;
box-shadow: none;
}
.panel.open {
width: 400px;
opacity: 1;
border-left-color: var(--border);
box-shadow: var(--shadow-lg);
animation: slideInRight 0.25s ease-out both;
}

View File

@@ -23,51 +23,54 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
const panel = (
<aside
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
aria-hidden={!open}
>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<button
className={styles.closeBtn}
onClick={onClose}
aria-label="Close panel"
type="button"
>
&times;
</button>
</div>
{tabs && tabs.length > 0 && (
<div className={styles.tabs}>
{tabs.map((tab) => (
<button
key={tab.value}
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
onClick={() => setActiveTab(tab.value)}
type="button"
>
{tab.label}
</button>
))}
const content = (
<>
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
<aside
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
aria-hidden={!open}
>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<button
className={styles.closeBtn}
onClick={onClose}
aria-label="Close panel"
type="button"
>
&times;
</button>
</div>
)}
<div className={styles.body}>
{children ?? activeContent}
</div>
{tabs && tabs.length > 0 && (
<div className={styles.tabs}>
{tabs.map((tab) => (
<button
key={tab.value}
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
onClick={() => setActiveTab(tab.value)}
type="button"
>
{tab.label}
</button>
))}
</div>
)}
{actions && (
<div className={styles.actions}>
{actions}
<div className={styles.body}>
{children ?? activeContent}
</div>
)}
</aside>
{actions && (
<div className={styles.actions}>
{actions}
</div>
)}
</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
return portalTarget ? createPortal(content, portalTarget) : content
}

View File

@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
import styles from './EventFeed.module.css'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
return ''
}
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
error: '\u2715', // ✕
warning: '\u26A0', // ⚠
success: '\u25B6', // ▶
running: '\u2699', // ⚙
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
error: <XIcon size={14} />,
warning: <AlertTriangle size={14} />,
success: <Play size={14} />,
running: <Loader size={14} />,
}
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
// Auto-scroll to bottom
const scrollToBottom = useCallback(() => {
// Auto-scroll to top (newest entries are at top in desc sort)
const scrollToTop = useCallback(() => {
const el = scrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
el.scrollTop = 0
}
}, [])
useEffect(() => {
if (!isPaused) {
scrollToBottom()
scrollToTop()
}
}, [events, isPaused, scrollToBottom])
}, [events, isPaused, scrollToTop])
function handleScroll() {
const el = scrollRef.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
setIsPaused(!atBottom)
const atTop = el.scrollTop < 8
setIsPaused(!atTop)
}
function toggleFilter(severity: SeverityFilter) {
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
onClick={() => setSearch('')}
aria-label="Clear search"
>
×
<XIcon size={12} />
</button>
)}
</div>
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
className={styles.resumeBtn}
onClick={() => {
setIsPaused(false)
scrollToBottom()
scrollToTop()
}}
>
Resume auto-scroll
&uarr; Scroll to latest
</button>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useState, type ChangeEvent } from 'react'
import { Search } from 'lucide-react'
import styles from './FilterBar.module.css'
import { Input } from '../../primitives/Input/Input'
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
@@ -77,12 +78,7 @@ export function FilterBar({
if (onSearchChange) onSearchChange('')
else setInternalSearch('')
} : undefined}
icon={
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
}
icon={<Search size={13} />}
/>
</div>

View File

@@ -1,11 +1,11 @@
import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties } from 'react'
import type { CSSProperties, ReactNode } from 'react'
export interface KpiItem {
label: string
value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string

View File

@@ -1,7 +1,5 @@
.container {
overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0;
font-family: var(--font-mono);
}
@@ -58,6 +56,11 @@
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
.levelTrace {
color: var(--text-faint);
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
}
.message {
font-size: 12px;
font-family: var(--font-mono);

View File

@@ -7,6 +7,7 @@ const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
]
describe('LogViewer', () => {
@@ -16,14 +17,16 @@ describe('LogViewer', () => {
expect(screen.getByText('High memory usage')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument()
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
})
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument()
expect(screen.getByText('TRACE')).toBeInTheDocument()
})
it('renders with custom maxHeight (number)', () => {

View File

@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
message: string
}
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
trace: styles.levelTrace,
}
function formatTime(iso: string): string {
@@ -35,18 +36,18 @@ function formatTime(iso: string): string {
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const isAtTopRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
isAtTopRef.current = el.scrollTop < 20
}, [])
useEffect(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) {
el.scrollTop = el.scrollHeight
if (el && isAtTopRef.current) {
el.scrollTop = 0
}
}, [entries])

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,8 @@
import type { ReactNode } from 'react'
import { EllipsisVertical } from 'lucide-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 +10,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 +26,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 +41,8 @@ export function ProcessorTimeline({
totalMs,
onProcessorClick,
selectedIndex,
actions,
getActions,
className,
}: ProcessorTimelineProps) {
const safeTotal = totalMs || 1
@@ -70,6 +87,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 +109,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"
>
<EllipsisVertical size={14} />
</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,13 @@
import type { ReactNode } from 'react'
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-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 +15,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
}
@@ -29,12 +56,12 @@ function durationClass(ms: number, status: string): string {
return styles.durBreach
}
const TYPE_ICONS: Record<string, string> = {
'from': '\u25B6',
'process': '\u2699',
'to': '\u25A2',
'choice': '\u25C6',
'error-handler': '\u26A0',
const TYPE_ICONS: Record<string, ReactNode> = {
'from': <Play size={14} />,
'process': <Cog size={14} />,
'to': <Square size={14} />,
'choice': <Diamond size={14} />,
'error-handler': <AlertTriangle size={14} />,
}
const ICON_CLASSES: Record<string, string> = {
@@ -52,12 +79,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"
>
<EllipsisVertical size={14} />
</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] ?? <Square size={14} />}
</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 +226,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,9 +245,23 @@ 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'}
{TYPE_ICONS[node.type] ?? <Square size={14} />}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
@@ -102,6 +272,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
</div>
</div>
)
@@ -110,22 +281,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

@@ -89,7 +89,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for success variant', () => {
@@ -97,7 +97,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for warning variant', () => {
@@ -105,7 +105,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for error variant', () => {
@@ -113,7 +113,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('dismisses toast when close button is clicked', () => {

View File

@@ -8,6 +8,7 @@ import {
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Toast.module.css'
// ── Types ──────────────────────────────────────────────────────────────────
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
const DEFAULT_DURATION = 5000
const EXIT_ANIMATION_MS = 300
const ICONS: Record<ToastVariant, string> = {
info: '',
success: '✓',
warning: '⚠',
error: '✕',
const ICONS: Record<ToastVariant, ReactNode> = {
info: <Info size={16} />,
success: <CheckCircle size={16} />,
warning: <AlertTriangle size={16} />,
error: <XCircle size={16} />,
}
// ── Context ────────────────────────────────────────────────────────────────
@@ -183,7 +184,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
aria-label="Dismiss notification"
type="button"
>
&times;
<X size={14} />
</button>
</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

@@ -7,5 +7,7 @@ export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider'
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'

View File

@@ -2,6 +2,7 @@
display: flex;
height: 100vh;
overflow: hidden;
position: relative;
}
.main {

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg'
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
@@ -55,7 +56,7 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
id: `route:${app.id}:${route.id}`,
starKey: `${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
icon: <ChevronRight size={12} />,
badge: formatCount(route.exchangeCount),
path: `/apps/${app.id}/${route.id}`,
starrable: true,
@@ -78,7 +79,7 @@ function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
id: `routestat:${app.id}:${route.id}`,
starKey: `routes:${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
icon: <ChevronRight size={12} />,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
starrable: true,
@@ -235,10 +236,7 @@ function StarredGroup({
tabIndex={-1}
aria-label={`Remove ${item.label} from starred`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<X size={12} />
</button>
</div>
))}
@@ -344,10 +342,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
<div className={styles.searchWrap}>
<div className={styles.searchInner}>
<span className={styles.searchIcon} aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<Search size={12} />
</span>
<input
className={styles.searchInput}
@@ -363,7 +358,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
onClick={() => setSearch('')}
aria-label="Clear search"
>
×
<X size={12} />
</button>
)}
</div>
@@ -382,7 +377,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
aria-expanded={!appsCollapsed}
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
>
{appsCollapsed ? '▸' : '▾'}
{appsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
@@ -416,7 +411,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
>
{agentsCollapsed ? '▸' : '▾'}
{agentsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
@@ -450,7 +445,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
aria-expanded={!routesCollapsed}
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
>
{routesCollapsed ? '▸' : '▾'}
{routesCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
@@ -536,7 +531,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
>
<span className={styles.bottomIcon}>&#9881;</span>
<span className={styles.bottomIcon}><Settings size={14} /></span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>Admin</div>
</div>
@@ -551,7 +546,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
>
<span className={styles.bottomIcon}>&#9776;</span>
<span className={styles.bottomIcon}><FileText size={14} /></span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
type MouseEvent,
} from 'react'
import { useNavigate } from 'react-router-dom'
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
import styles from './Sidebar.module.css'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -35,22 +36,14 @@ export interface SidebarTreeProps {
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
}
// ── Star icon SVGs ───────────────────────────────────────────────────────────
// ── Star icons ───────────────────────────────────────────────────────────────
function StarOutline() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
return <Star size={14} />
}
function StarFilled() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
return <Star size={14} fill="currentColor" />
}
// ── Persistent expand state ──────────────────────────────────────────────────
@@ -395,7 +388,7 @@ function SidebarTreeRow({
tabIndex={-1}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? '▾' : '▸'}
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : null}
</span>

View File

@@ -1,3 +1,4 @@
import { Search, Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown'
@@ -8,11 +9,8 @@ import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeD
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider'
interface BreadcrumbItem {
label: string
href?: string
}
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
interface TopBarProps {
breadcrumb: BreadcrumbItem[]
@@ -39,11 +37,12 @@ export function TopBar({
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme()
const breadcrumbOverride = useBreadcrumbOverride()
return (
<header className={`${styles.topbar} ${className ?? ''}`}>
{/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
{/* Search trigger */}
<button
@@ -53,10 +52,7 @@ export function TopBar({
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<Search size={13} />
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
@@ -103,7 +99,7 @@ export function TopBar({
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '\u263E' : '\u2600'}
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</button>
{environment && (
<span className={styles.env}>{environment}</span>
@@ -117,7 +113,7 @@ export function TopBar({
</div>
}
items={[
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
]}
/>
)}

View File

@@ -46,24 +46,23 @@ describe('Alert', () => {
})
it('shows default icon for each variant', () => {
const { rerender } = render(<Alert variant="info">msg</Alert>)
expect(screen.getByText('')).toBeInTheDocument()
const { container, rerender } = render(<Alert variant="info">msg</Alert>)
// Each variant should render an SVG icon in the icon slot
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="success">msg</Alert>)
expect(screen.getByText('✓')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="warning">msg</Alert>)
expect(screen.getByText('⚠')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="error">msg</Alert>)
expect(screen.getByText('✕')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders a custom icon when provided', () => {
render(<Alert icon={<span></span>}>Custom icon alert</Alert>)
expect(screen.getByText('')).toBeInTheDocument()
// Default icon should not appear
expect(screen.queryByText('')).not.toBeInTheDocument()
render(<Alert icon={<span data-testid="custom-icon"></span>}>Custom icon alert</Alert>)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('does not show dismiss button when dismissible is false', () => {

View File

@@ -1,4 +1,5 @@
import { ReactNode } from 'react'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Alert.module.css'
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
@@ -13,11 +14,11 @@ interface AlertProps {
className?: string
}
const DEFAULT_ICONS: Record<AlertVariant, string> = {
info: '',
success: '✓',
warning: '⚠',
error: '✕',
const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
info: <Info size={16} />,
success: <CheckCircle size={16} />,
warning: <AlertTriangle size={16} />,
error: <XCircle size={16} />,
}
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
@@ -61,7 +62,7 @@ export function Alert({
aria-label="Dismiss alert"
type="button"
>
&times;
<X size={14} />
</button>
)}
</div>

View File

@@ -1,5 +1,6 @@
import styles from './Input.module.css'
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
import { X } from 'lucide-react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
onClick={onClear}
aria-label="Clear search"
>
×
<X size={12} />
</button>
)}
</div>

View File

@@ -0,0 +1,44 @@
import { createContext, useContext, useState, useEffect } from 'react'
import type { ReactNode } from 'react'
export interface BreadcrumbItem {
label: string
href?: string
}
interface BreadcrumbContextValue {
override: BreadcrumbItem[] | null
setOverride: (items: BreadcrumbItem[] | null) => void
}
const BreadcrumbContext = createContext<BreadcrumbContextValue>({
override: null,
setOverride: () => {},
})
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [override, setOverride] = useState<BreadcrumbItem[] | null>(null)
return (
<BreadcrumbContext.Provider value={{ override, setOverride }}>
{children}
</BreadcrumbContext.Provider>
)
}
/**
* Override the TopBar breadcrumb with page-specific semantic items.
* Pass `null` to clear (or let unmount handle it).
* Callers should `useMemo` the items array to avoid unnecessary re-renders.
*/
export function useBreadcrumb(items: BreadcrumbItem[] | null) {
const { setOverride } = useContext(BreadcrumbContext)
useEffect(() => {
setOverride(items)
return () => setOverride(null)
}, [items, setOverride])
}
/** Internal — used by TopBar to read the current override. */
export function useBreadcrumbOverride(): BreadcrumbItem[] | null {
return useContext(BreadcrumbContext).override
}

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

@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react'
import { useParams, Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import styles from './AgentHealth.module.css'
// Layout
@@ -389,7 +390,7 @@ export function AgentHealth() {
{scope.level !== 'all' && (
<>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
<span className={styles.scopeCurrent}>{scope.appId}</span>
</>
)}

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { useParams, Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import styles from './AgentInstance.module.css'
// Layout
@@ -177,9 +178,9 @@ export function AgentInstance() {
{/* Scope trail + badges */}
<div className={styles.scopeTrail}>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
<span className={styles.scopeCurrent}>{agent.name}</span>
<Badge label={agent.status.toUpperCase()} color={statusColor} />
<Badge label={agent.version} color="auto" variant="outlined" />

View File

@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react'
import React, { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
import styles from './Dashboard.module.css'
// Layout
@@ -43,10 +44,10 @@ const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
warning: 'var(--warning)',
}
const TREND_ICONS: Record<KpiMetric['trend'], string> = {
up: '\u2191',
down: '\u2193',
neutral: '\u2192',
const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
up: <TrendingUp size={12} />,
down: <TrendingDown size={12} />,
neutral: <ArrowRight size={12} />,
}
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
@@ -60,7 +61,7 @@ function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' |
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
label: m.label,
value: m.unit ? `${m.value} ${m.unit}` : m.value,
trend: { label: `${TREND_ICONS[m.trend]} ${m.trendValue}`, variant: sentimentToVariant(m.trendSentiment) },
trend: { label: <><span style={{ display: 'inline-flex', verticalAlign: 'middle' }}>{TREND_ICONS[m.trend]}</span> {m.trendValue}</>, variant: sentimentToVariant(m.trendSentiment) },
subtitle: m.detail,
sparkline: m.sparkline,
borderColor: ACCENT_TO_COLOR[m.accent],
@@ -206,7 +207,7 @@ export function Dashboard() {
navigate(`/exchanges/${row.id}`)
}}
>
&#x2197;
<ExternalLink size={14} />
</button>
),
}
@@ -303,7 +304,7 @@ export function Dashboard() {
className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
>
Open full details &#x2192;
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
</button>
</div>
@@ -428,7 +429,7 @@ export function Dashboard() {
expandedContent={(row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}></span>
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
<div>
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { Hexagon, ArrowRight, Diamond, Eye, Pencil, RotateCcw, Trash2, ChevronDown } from 'lucide-react'
import styles from './CompositesSection.module.css'
import {
Accordion,
@@ -157,25 +158,25 @@ const TREE_NODES = [
{
id: 'app1',
label: 'cameleer-prod',
icon: '⬡',
icon: <Hexagon size={14} />,
children: [
{
id: 'route1',
label: 'order-ingest',
icon: '→',
icon: <ArrowRight size={14} />,
children: [
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' },
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' },
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' },
{ id: 'proc1', label: 'ValidateOrder', icon: <Diamond size={12} />, meta: '12ms' },
{ id: 'proc2', label: 'EnrichPayload', icon: <Diamond size={12} />, meta: '8ms' },
{ id: 'proc3', label: 'RouteToQueue', icon: <Diamond size={12} />, meta: '3ms' },
],
},
{
id: 'route2',
label: 'payment-validate',
icon: '→',
icon: <ArrowRight size={14} />,
children: [
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' },
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' },
{ id: 'proc4', label: 'TokenizeCard', icon: <Diamond size={12} />, meta: '22ms' },
{ id: 'proc5', label: 'AuthorizePayment', icon: <Diamond size={12} />, meta: '45ms' },
],
},
],
@@ -505,13 +506,13 @@ export function CompositesSection() {
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
>
<Dropdown
trigger={<Button size="sm" variant="secondary">Actions </Button>}
trigger={<Button size="sm" variant="secondary">Actions <ChevronDown size={12} /></Button>}
items={[
{ label: 'View details', icon: '👁', onClick: () => undefined },
{ label: 'Edit route', icon: '✏', onClick: () => undefined },
{ label: 'View details', icon: <Eye size={14} />, onClick: () => undefined },
{ label: 'Edit route', icon: <Pencil size={14} />, onClick: () => undefined },
{ divider: true, label: '' },
{ label: 'Restart', icon: '↺', onClick: () => undefined },
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true },
{ label: 'Restart', icon: <RotateCcw size={14} />, onClick: () => undefined },
{ label: 'Delete', icon: <Trash2 size={14} />, onClick: () => undefined, disabled: true },
]}
/>
</DemoCard>
@@ -769,7 +770,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 +781,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 +794,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 +808,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>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { Search } from 'lucide-react'
import styles from './PrimitivesSection.module.css'
import {
Alert,
@@ -358,7 +359,7 @@ export function PrimitivesSection() {
description="Text input with optional leading icon and placeholder."
>
<Input placeholder="Plain input" />
<Input icon="🔍" placeholder="With icon" />
<Input icon={<Search size={14} />} placeholder="With icon" />
</DemoCard>
{/* 15b. InlineEdit */}

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { AlertTriangle } from 'lucide-react'
import styles from './RouteDetail.module.css'
// Layout
@@ -337,7 +338,7 @@ export function RouteDetail() {
expandedContent={(row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}></span>
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
<div>
<div className={styles.errorClass}>{row.errorClass}</div>
<div className={styles.errorText}>{row.errorMessage}</div>