Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26de5ec58f | ||
|
|
d26dc6a8a5 | ||
|
|
c0b1cbdc5b | ||
|
|
d101d883a9 | ||
|
|
2a020c1e15 | ||
|
|
19303eefad | ||
|
|
5fe6321d30 | ||
|
|
90e3de2cdf | ||
|
|
499c86b680 | ||
|
|
63e16d2685 |
@@ -15,7 +15,21 @@
|
|||||||
"Bash(echo \"EXIT:$?\")",
|
"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(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_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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
165
e2e/admin.spec.ts
Normal file
165
e2e/admin.spec.ts
Normal 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
80
e2e/agents.spec.ts
Normal 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
90
e2e/dashboard.spec.ts
Normal 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
60
e2e/exchanges.spec.ts
Normal 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
63
e2e/routes.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -277,6 +277,23 @@
|
|||||||
overflow-y: auto;
|
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 */
|
/* Match highlight */
|
||||||
.mark {
|
.mark {
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface CommandPaletteProps {
|
|||||||
onSelect: (result: SearchResult) => void
|
onSelect: (result: SearchResult) => void
|
||||||
data: SearchResult[]
|
data: SearchResult[]
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
|
onQueryChange?: (query: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||||
@@ -60,7 +61,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
|||||||
return <>{parts}</>
|
return <>{parts}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
|
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange }: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
||||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||||
@@ -91,22 +92,17 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Filter results
|
// Stage 1: apply text query + scope filters (used for counts)
|
||||||
const filtered = useMemo(() => {
|
const queryFiltered = useMemo(() => {
|
||||||
let results = data
|
let results = data
|
||||||
|
|
||||||
if (activeCategory !== 'all') {
|
|
||||||
results = results.filter((r) => r.category === activeCategory)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
results = results.filter(
|
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) {
|
for (const sf of scopeFilters) {
|
||||||
results = results.filter((r) =>
|
results = results.filter((r) =>
|
||||||
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
||||||
@@ -114,7 +110,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
}
|
}
|
||||||
|
|
||||||
return results
|
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
|
// Group results by category
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
@@ -129,14 +131,14 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
// Flatten for keyboard nav
|
// Flatten for keyboard nav
|
||||||
const flatResults = useMemo(() => filtered, [filtered])
|
const flatResults = useMemo(() => filtered, [filtered])
|
||||||
|
|
||||||
// Counts per category
|
// Counts per category (from query-filtered, before category filter)
|
||||||
const categoryCounts = useMemo(() => {
|
const categoryCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = { all: data.length }
|
const counts: Record<string, number> = { all: queryFiltered.length }
|
||||||
for (const r of data) {
|
for (const r of queryFiltered) {
|
||||||
counts[r.category] = (counts[r.category] ?? 0) + 1
|
counts[r.category] = (counts[r.category] ?? 0) + 1
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}, [data])
|
}, [queryFiltered])
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@@ -208,6 +210,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value)
|
setQuery(e.target.value)
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
|
onQueryChange?.(e.target.value)
|
||||||
}}
|
}}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
/>
|
/>
|
||||||
@@ -301,6 +304,12 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
<div className={styles.itemMeta}>
|
<div className={styles.itemMeta}>
|
||||||
{highlightText(result.meta, query)}
|
{highlightText(result.meta, query)}
|
||||||
</div>
|
</div>
|
||||||
|
{result.matchContext && (
|
||||||
|
<div
|
||||||
|
className={styles.matchContext}
|
||||||
|
dangerouslySetInnerHTML={{ __html: result.matchContext }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{result.expandedContent && (
|
{result.expandedContent && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export interface SearchResult {
|
|||||||
path?: string
|
path?: string
|
||||||
expandedContent?: string
|
expandedContent?: string
|
||||||
matchRanges?: [number, number][]
|
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 {
|
export interface ScopeFilter {
|
||||||
|
|||||||
@@ -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 {
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100vh;
|
||||||
width: 0;
|
width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: width 0.25s ease, opacity 0.2s ease;
|
transition: width 0.25s ease, opacity 0.2s ease;
|
||||||
@@ -7,13 +24,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
flex-shrink: 0;
|
z-index: 100;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel.open {
|
.panel.open {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-left-color: var(--border);
|
border-left-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
animation: slideInRight 0.25s ease-out both;
|
animation: slideInRight 0.25s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,51 +23,54 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
|||||||
|
|
||||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||||
|
|
||||||
const panel = (
|
const content = (
|
||||||
<aside
|
<>
|
||||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
|
||||||
aria-hidden={!open}
|
<aside
|
||||||
>
|
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||||
<div className={styles.header}>
|
aria-hidden={!open}
|
||||||
<span className={styles.title}>{title}</span>
|
>
|
||||||
<button
|
<div className={styles.header}>
|
||||||
className={styles.closeBtn}
|
<span className={styles.title}>{title}</span>
|
||||||
onClick={onClose}
|
<button
|
||||||
aria-label="Close panel"
|
className={styles.closeBtn}
|
||||||
type="button"
|
onClick={onClose}
|
||||||
>
|
aria-label="Close panel"
|
||||||
×
|
type="button"
|
||||||
</button>
|
>
|
||||||
</div>
|
×
|
||||||
|
</button>
|
||||||
{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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.body}>
|
{tabs && tabs.length > 0 && (
|
||||||
{children ?? activeContent}
|
<div className={styles.tabs}>
|
||||||
</div>
|
{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.body}>
|
||||||
<div className={styles.actions}>
|
{children ?? activeContent}
|
||||||
{actions}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
{actions && (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Portal to AppShell level if target exists, otherwise render in place
|
// Portal to AppShell level if target exists, otherwise render in place
|
||||||
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||||
return portalTarget ? createPortal(panel, portalTarget) : panel
|
return portalTarget ? createPortal(content, portalTarget) : content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,25 +81,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
||||||
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to top (newest entries are at top in desc sort)
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPaused) {
|
if (!isPaused) {
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}
|
}
|
||||||
}, [events, isPaused, scrollToBottom])
|
}, [events, isPaused, scrollToTop])
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
|
const atTop = el.scrollTop < 8
|
||||||
setIsPaused(!atBottom)
|
setIsPaused(!atTop)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFilter(severity: SeverityFilter) {
|
function toggleFilter(severity: SeverityFilter) {
|
||||||
@@ -196,10 +196,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
className={styles.resumeBtn}
|
className={styles.resumeBtn}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPaused(false)
|
setIsPaused(false)
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↓ Resume auto-scroll
|
↑ Scroll to latest
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
.container {
|
.container {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg-inset);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,18 +35,18 @@ function formatTime(iso: string): string {
|
|||||||
|
|
||||||
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const isAtBottomRef = useRef(true)
|
const isAtTopRef = useRef(true)
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
isAtTopRef.current = el.scrollTop < 20
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el && isAtBottomRef.current) {
|
if (el && isAtTopRef.current) {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [entries])
|
}, [entries])
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,25 @@
|
|||||||
color: var(--text-primary);
|
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 {
|
.empty {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import styles from './ProcessorTimeline.module.css'
|
import styles from './ProcessorTimeline.module.css'
|
||||||
import { Dropdown } from '../Dropdown/Dropdown'
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
||||||
|
|
||||||
export interface ProcessorStep {
|
export interface ProcessorStep {
|
||||||
name: string
|
name: string
|
||||||
@@ -8,6 +9,7 @@ export interface ProcessorStep {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
startMs: number
|
startMs: number
|
||||||
|
badges?: NodeBadge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessorAction {
|
export interface ProcessorAction {
|
||||||
@@ -84,6 +86,16 @@ export function ProcessorTimeline({
|
|||||||
>
|
>
|
||||||
<div className={styles.name} title={proc.name}>
|
<div className={styles.name} title={proc.name}>
|
||||||
{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>
|
||||||
<div className={styles.barBg}>
|
<div className={styles.barBg}>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -222,17 +222,66 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottleneck badge */
|
/* Badges */
|
||||||
.bottleneckBadge {
|
.badgeRow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -7px;
|
top: -7px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--error);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
letter-spacing: 0.3px;
|
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); }
|
||||||
|
|||||||
@@ -81,3 +81,80 @@ describe('RouteFlow', () => {
|
|||||||
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import type { ReactNode } from 'react'
|
|||||||
import styles from './RouteFlow.module.css'
|
import styles from './RouteFlow.module.css'
|
||||||
import { Dropdown } from '../Dropdown/Dropdown'
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
|
||||||
|
export interface NodeBadge {
|
||||||
|
label: string
|
||||||
|
variant?: 'info' | 'success' | 'warning' | 'error'
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface RouteNode {
|
export interface RouteNode {
|
||||||
name: string
|
name: string
|
||||||
type: 'from' | 'process' | 'to' | 'choice' | 'error-handler'
|
type: 'from' | 'process' | 'to' | 'choice' | 'error-handler'
|
||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
isBottleneck?: boolean
|
isBottleneck?: boolean
|
||||||
|
badges?: NodeBadge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeAction {
|
export interface NodeAction {
|
||||||
@@ -18,8 +25,15 @@ export interface NodeAction {
|
|||||||
divider?: boolean
|
divider?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteFlowProps {
|
export interface FlowSegment {
|
||||||
|
label: string
|
||||||
nodes: RouteNode[]
|
nodes: RouteNode[]
|
||||||
|
variant?: 'default' | 'error' | 'warning' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteFlowProps {
|
||||||
|
nodes?: RouteNode[]
|
||||||
|
flows?: FlowSegment[]
|
||||||
onNodeClick?: (node: RouteNode, index: number) => void
|
onNodeClick?: (node: RouteNode, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
actions?: NodeAction[]
|
actions?: NodeAction[]
|
||||||
@@ -95,12 +109,110 @@ function renderActionTrigger(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
const FLOW_LABEL_CLASSES: Record<string, string> = {
|
||||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
'default': styles.flowLabelDefault,
|
||||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
'error': styles.flowLabelError,
|
||||||
|
'warning': styles.flowLabelWarning,
|
||||||
|
'info': styles.flowLabelInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeChain(
|
||||||
|
nodes: RouteNode[],
|
||||||
|
globalIndexOffset: number,
|
||||||
|
onNodeClick?: RouteFlowProps['onNodeClick'],
|
||||||
|
selectedIndex?: number,
|
||||||
|
actions?: NodeAction[],
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||||
|
) {
|
||||||
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
|
return nodes.map((node, i) => {
|
||||||
|
const globalIndex = globalIndexOffset + i
|
||||||
|
const isSelected = selectedIndex === globalIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
|
{i > 0 && (
|
||||||
|
<div className={styles.connector}>
|
||||||
|
<div className={styles.connectorLine} />
|
||||||
|
<div className={styles.connectorArrow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
|
||||||
|
onClick={() => onNodeClick?.(node, globalIndex)}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault()
|
||||||
|
onNodeClick?.(node, globalIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(node.isBottleneck || node.badges?.length) ? (
|
||||||
|
<span className={styles.badgeRow}>
|
||||||
|
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||||
|
{node.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
|
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.type}>{node.type}</div>
|
||||||
|
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||||
|
{formatDuration(node.durationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
||||||
|
// Multi-flow mode
|
||||||
|
if (flows && flows.length > 0) {
|
||||||
|
let globalOffset = 0
|
||||||
|
return (
|
||||||
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
|
{flows.map((flow, fi) => {
|
||||||
|
const sectionOffset = globalOffset
|
||||||
|
globalOffset += flow.nodes.length
|
||||||
|
const variant = flow.variant ?? 'default'
|
||||||
|
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
|
||||||
|
return (
|
||||||
|
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
|
||||||
|
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
|
||||||
|
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy mode (single nodes array with automatic error-handler separation)
|
||||||
|
const allNodes = nodes ?? []
|
||||||
|
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
|
||||||
|
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
|
||||||
|
|
||||||
// Map from mainNodes index back to original nodes index
|
// 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)
|
if (n.type !== 'error-handler') acc.push(idx)
|
||||||
return acc
|
return acc
|
||||||
}, [])
|
}, [])
|
||||||
@@ -113,7 +225,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
|
|||||||
const isClickable = !!onNodeClick
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
{i > 0 && (
|
{i > 0 && (
|
||||||
<div className={styles.connector}>
|
<div className={styles.connector}>
|
||||||
<div className={styles.connectorLine} />
|
<div className={styles.connectorLine} />
|
||||||
@@ -132,7 +244,21 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{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}`}>
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +281,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
|
|||||||
<div className={styles.errorSection}>
|
<div className={styles.errorSection}>
|
||||||
<div className={styles.errorLabel}>Error Handler</div>
|
<div className={styles.errorLabel}>Error Handler</div>
|
||||||
{errorHandlers.map((node, i) => {
|
{errorHandlers.map((node, i) => {
|
||||||
const errOriginalIndex = nodes.indexOf(node)
|
const errOriginalIndex = allNodes.indexOf(node)
|
||||||
return (
|
return (
|
||||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export { Popover } from './Popover/Popover'
|
|||||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||||
export type { RouteNode, NodeAction } from './RouteFlow/RouteFlow'
|
export type { RouteNode, NodeAction, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
|
||||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||||
export { SplitPane } from './SplitPane/SplitPane'
|
export { SplitPane } from './SplitPane/SplitPane'
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ export * from './layout'
|
|||||||
export * from './providers/ThemeProvider'
|
export * from './providers/ThemeProvider'
|
||||||
export * from './providers/CommandPaletteProvider'
|
export * from './providers/CommandPaletteProvider'
|
||||||
export * from './providers/GlobalFilterProvider'
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider'
|
||||||
|
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
|
||||||
export * from './utils/hashColor'
|
export * from './utils/hashColor'
|
||||||
export * from './utils/timePresets'
|
export * from './utils/timePresets'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeD
|
|||||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
||||||
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||||
import { useTheme } from '../../providers/ThemeProvider'
|
import { useTheme } from '../../providers/ThemeProvider'
|
||||||
|
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||||
interface BreadcrumbItem {
|
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||||
label: string
|
|
||||||
href?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
@@ -39,11 +36,12 @@ export function TopBar({
|
|||||||
const globalFilters = useGlobalFilters()
|
const globalFilters = useGlobalFilters()
|
||||||
const commandPalette = useCommandPalette()
|
const commandPalette = useCommandPalette()
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const breadcrumbOverride = useBreadcrumbOverride()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||||
|
|
||||||
{/* Search trigger */}
|
{/* Search trigger */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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'
|
import { computePresetRange } from '../utils/timePresets'
|
||||||
|
|
||||||
export interface TimeRange {
|
export interface TimeRange {
|
||||||
@@ -66,6 +66,16 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
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(
|
const isInTimeRange = useCallback(
|
||||||
(timestamp: Date) => {
|
(timestamp: Date) => {
|
||||||
if (timeRange.preset) {
|
if (timeRange.preset) {
|
||||||
|
|||||||
@@ -815,6 +815,37 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 17c. RouteFlow (Multi-Flow) */}
|
||||||
|
<DemoCard
|
||||||
|
id="routeflow-multi"
|
||||||
|
title="RouteFlow (Multi-Flow)"
|
||||||
|
description="Multiple flow segments with labels, showing a main route alongside an exception handler."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
<RouteFlow
|
||||||
|
flows={[
|
||||||
|
{
|
||||||
|
label: 'Main Route',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
|
||||||
|
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
|
||||||
|
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow' },
|
||||||
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'onException(IOException)',
|
||||||
|
variant: 'error',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'log:error-logger', type: 'process', durationMs: 2, status: 'ok' },
|
||||||
|
{ name: 'dead-letter:failed-orders', type: 'to', durationMs: 14, status: 'fail' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 18. ShortcutsBar */}
|
{/* 18. ShortcutsBar */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="shortcutsbar"
|
id="shortcutsbar"
|
||||||
|
|||||||
Reference in New Issue
Block a user