Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
592b60c5fe | ||
|
|
0bb49b83e5 | ||
|
|
8070fdea7c | ||
|
|
7830ac5e0d | ||
|
|
fdccca5378 | ||
|
|
0d4215678a | ||
|
|
28690b2a7a | ||
|
|
5eb807c572 | ||
|
|
f359a2ba3d | ||
|
|
384ee97643 | ||
|
|
a12b374fb2 | ||
|
|
433d582da6 | ||
|
|
2ffc268b44 | ||
|
|
99ae66315b | ||
|
|
26de5ec58f | ||
|
|
d26dc6a8a5 | ||
|
|
c0b1cbdc5b | ||
|
|
d101d883a9 | ||
|
|
2a020c1e15 | ||
|
|
19303eefad | ||
|
|
5fe6321d30 | ||
|
|
90e3de2cdf | ||
|
|
499c86b680 | ||
|
|
63e16d2685 | ||
|
|
19dccb8685 | ||
|
|
4b873194c9 | ||
|
|
5f1b039056 | ||
|
|
095abe1751 | ||
|
|
e8859e53ce | ||
|
|
021f6c7811 | ||
|
|
c18ba7d085 | ||
|
|
795ffef9dc | ||
|
|
039f2fa5fe |
@@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npx vitest run
|
run: npx vitest run --exclude 'e2e/**'
|
||||||
|
|
||||||
- name: Build library
|
- name: Build library
|
||||||
run: npm run build:lib
|
run: npm run build:lib
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*)
|
refs/tags/v*)
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
npm version "$VERSION" --no-git-tag-version
|
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
62
.gitea/workflows/sonarqube.yml
Normal file
62
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: SonarQube Analysis
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sonarqube:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: npx vitest run --exclude 'e2e/**' --coverage --coverage.reporter=lcov
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: Install sonar-scanner
|
||||||
|
run: |
|
||||||
|
SONAR_SCANNER_VERSION=6.2.1.4610
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
PLATFORM="linux-aarch64"
|
||||||
|
else
|
||||||
|
PLATFORM="linux-x64"
|
||||||
|
fi
|
||||||
|
curl -sSLo sonar-scanner.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-${PLATFORM}.zip"
|
||||||
|
unzip -q sonar-scanner.zip
|
||||||
|
echo "$PWD/sonar-scanner-${SONAR_SCANNER_VERSION}-${PLATFORM}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run SonarQube analysis
|
||||||
|
env:
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$SONAR_HOST_URL" ] || ! echo "$SONAR_HOST_URL" | grep -qE '^https?://'; then
|
||||||
|
echo "::error::SONAR_HOST_URL is missing or invalid (got: '$SONAR_HOST_URL'). Set it as a repo variable with full URL (e.g. https://sonar.example.com)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sonar-scanner \
|
||||||
|
-Dsonar.host.url="$SONAR_HOST_URL" \
|
||||||
|
-Dsonar.login="$SONAR_TOKEN" \
|
||||||
|
-Dsonar.projectKey=cameleer-design-system \
|
||||||
|
-Dsonar.projectName="Cameleer Design System" \
|
||||||
|
-Dsonar.sources=src/design-system \
|
||||||
|
-Dsonar.tests=src/design-system \
|
||||||
|
-Dsonar.test.inclusions="**/*.test.tsx,**/*.test.ts" \
|
||||||
|
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info \
|
||||||
|
-Dsonar.exclusions="**/node_modules/**,**/dist/**"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
|
|||||||
// Utils
|
// Utils
|
||||||
import { hashColor } from '@cameleer/design-system'
|
import { hashColor } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Recharts theme (for advanced charts — treemap, radar, heatmap, etc.)
|
||||||
|
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
import type { ChartSeries, DataPoint } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Styles (once, at app root)
|
// Styles (once, at app root)
|
||||||
import '@cameleer/design-system/style.css'
|
import '@cameleer/design-system/style.css'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
- Time series → **LineChart**, **AreaChart**
|
- Time series → **LineChart**, **AreaChart**
|
||||||
- Categorical comparison → **BarChart**
|
- Categorical comparison → **BarChart**
|
||||||
- Inline trend → **Sparkline**
|
- Inline trend → **Sparkline**
|
||||||
|
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
|
||||||
- Event log → **EventFeed**
|
- Event log → **EventFeed**
|
||||||
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||||
- Processing pipeline (flow diagram) → **RouteFlow**
|
- Processing pipeline (flow diagram) → **RouteFlow**
|
||||||
@@ -160,6 +161,53 @@ StatCard strip (top, recalculates per scope)
|
|||||||
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
|
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Charting Strategy
|
||||||
|
|
||||||
|
The design system includes built-in **AreaChart**, **BarChart**, **LineChart**, and **Sparkline** components for standard use cases. For advanced chart types (treemap, radar, heatmap, pie, sankey, etc.), consuming apps should use **Recharts** directly with the design system's theme preset for visual consistency.
|
||||||
|
|
||||||
|
**Recharts is the app's dependency, not the design system's.** The design system only exports a theme config object.
|
||||||
|
|
||||||
|
### Setup in consuming app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install recharts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage with theme preset
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, LineChart, Line,
|
||||||
|
CartesianGrid, XAxis, YAxis, Tooltip, Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
<XAxis dataKey="name" {...rechartsTheme.xAxis} />
|
||||||
|
<YAxis {...rechartsTheme.yAxis} />
|
||||||
|
<Tooltip {...rechartsTheme.tooltip} />
|
||||||
|
<Legend {...rechartsTheme.legend} />
|
||||||
|
<Line dataKey="value" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
|
||||||
|
<Line dataKey="other" stroke={CHART_COLORS[1]} strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exports
|
||||||
|
|
||||||
|
| Export | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `rechartsTheme.cartesianGrid` | Dashed gridlines, subtle stroke |
|
||||||
|
| `rechartsTheme.xAxis` | Mono font axis ticks, subtle color |
|
||||||
|
| `rechartsTheme.yAxis` | Mono font axis ticks, no axis line |
|
||||||
|
| `rechartsTheme.tooltip` | Surface bg, border, shadow, monospace values |
|
||||||
|
| `rechartsTheme.legend` | Matching text size and color |
|
||||||
|
| `rechartsTheme.colors` | The 8 `CHART_COLORS` (CSS variables with light/dark support) |
|
||||||
|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
||||||
|
| `ChartSeries` / `DataPoint` | Type interfaces for chart data |
|
||||||
|
|
||||||
## Component Index
|
## Component Index
|
||||||
|
|
||||||
| Component | Layer | When to use |
|
| Component | Layer | When to use |
|
||||||
@@ -207,8 +255,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||||
| Pagination | primitive | Page navigation controls |
|
| Pagination | primitive | Page navigation controls |
|
||||||
| Popover | composite | Click-triggered floating panel with arrow |
|
| Popover | composite | Click-triggered floating panel with arrow |
|
||||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. |
|
||||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. |
|
||||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
814
package-lock.json
generated
814
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.0",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.es.js",
|
"main": "./dist/index.es.js",
|
||||||
"module": "./dist/index.es.js",
|
"module": "./dist/index.es.js",
|
||||||
@@ -12,8 +12,12 @@
|
|||||||
},
|
},
|
||||||
"./style.css": "./dist/style.css"
|
"./style.css": "./dist/style.css"
|
||||||
},
|
},
|
||||||
"files": ["dist"],
|
"files": [
|
||||||
"sideEffects": ["*.css"],
|
"dist"
|
||||||
|
],
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css"
|
||||||
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
},
|
},
|
||||||
@@ -27,9 +31,11 @@
|
|||||||
"build:lib": "vite build --config vite.lib.config.ts",
|
"build:lib": "vite build --config vite.lib.config.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
@@ -40,12 +46,14 @@
|
|||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"happy-dom": "^20.8.4",
|
"happy-dom": "^20.8.4",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
|
|||||||
|
|
||||||
it('renders danger variant icon', () => {
|
it('renders danger variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="danger" />)
|
render(<AlertDialog {...defaultProps} variant="danger" />)
|
||||||
// Icon area should be present (aria-hidden)
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders warning variant icon', () => {
|
it('renders warning variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="warning" />)
|
render(<AlertDialog {...defaultProps} variant="warning" />)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders info variant icon', () => {
|
it('renders info variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="info" />)
|
render(<AlertDialog {...defaultProps} variant="info" />)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { Modal } from '../Modal/Modal'
|
||||||
import { Button } from '../../primitives/Button/Button'
|
import { Button } from '../../primitives/Button/Button'
|
||||||
import styles from './AlertDialog.module.css'
|
import styles from './AlertDialog.module.css'
|
||||||
@@ -16,10 +17,10 @@ interface AlertDialogProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
|
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
|
||||||
danger: '✕',
|
danger: <XCircle size={20} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={20} />,
|
||||||
info: 'ℹ',
|
info: <Info size={20} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertDialog({
|
export function AlertDialog({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CommandPalette.module.css'
|
import styles from './CommandPalette.module.css'
|
||||||
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
||||||
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
||||||
@@ -12,12 +13,17 @@ interface CommandPaletteProps {
|
|||||||
onSelect: (result: SearchResult) => void
|
onSelect: (result: SearchResult) => void
|
||||||
data: SearchResult[]
|
data: SearchResult[]
|
||||||
onOpen?: () => void
|
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> = {
|
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
application: 'Applications',
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
|
attribute: 'Attributes',
|
||||||
route: 'Routes',
|
route: 'Routes',
|
||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
}
|
}
|
||||||
@@ -26,6 +32,7 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
|||||||
'all',
|
'all',
|
||||||
'application',
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
|
'attribute',
|
||||||
'route',
|
'route',
|
||||||
'agent',
|
'agent',
|
||||||
]
|
]
|
||||||
@@ -60,12 +67,13 @@ 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, onSubmit }: 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[]>([])
|
||||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const userNavigated = useRef(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -88,25 +96,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
setQuery('')
|
setQuery('')
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
setExpandedId(null)
|
setExpandedId(null)
|
||||||
|
userNavigated.current = false
|
||||||
}
|
}
|
||||||
}, [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 +118,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 +139,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) {
|
||||||
@@ -145,15 +155,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
break
|
break
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.max(i - 1, 0))
|
setFocusedIdx((i) => Math.max(i - 1, 0))
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (flatResults[focusedIdx]) {
|
if (!userNavigated.current && onSubmit && query.trim()) {
|
||||||
|
onSubmit(query.trim())
|
||||||
|
onClose()
|
||||||
|
} else if (flatResults[focusedIdx]) {
|
||||||
onSelect(flatResults[focusedIdx])
|
onSelect(flatResults[focusedIdx])
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -185,7 +200,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
>
|
>
|
||||||
{/* Search input area */}
|
{/* Search input area */}
|
||||||
<div className={styles.searchArea}>
|
<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) => (
|
{scopeFilters.map((sf, i) => (
|
||||||
<span key={i} className={styles.scopeTag}>
|
<span key={i} className={styles.scopeTag}>
|
||||||
<span className={styles.scopeField}>{sf.field}:</span>
|
<span className={styles.scopeField}>{sf.field}:</span>
|
||||||
@@ -195,7 +210,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onClick={() => removeScopeFilter(i)}
|
onClick={() => removeScopeFilter(i)}
|
||||||
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
||||||
>
|
>
|
||||||
×
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -208,6 +223,8 @@ 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)
|
||||||
|
userNavigated.current = false
|
||||||
|
onQueryChange?.(e.target.value)
|
||||||
}}
|
}}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
/>
|
/>
|
||||||
@@ -276,7 +293,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onSelect(result)
|
onSelect(result)
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setFocusedIdx(flatIdx)}
|
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
||||||
>
|
>
|
||||||
<div className={styles.itemMain}>
|
<div className={styles.itemMain}>
|
||||||
{result.icon && (
|
{result.icon && (
|
||||||
@@ -301,6 +318,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
|
||||||
@@ -312,7 +335,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-label="Toggle detail"
|
aria-label="Toggle detail"
|
||||||
>
|
>
|
||||||
{isExpanded ? '▲' : '▼'}
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +364,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Enter" />
|
<KeyboardHint keys="Enter" />
|
||||||
<span>Open</span>
|
<span>Search</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Esc" />
|
<KeyboardHint keys="Esc" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent'
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -12,6 +12,23 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fillHeight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -35,6 +52,9 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: color 0.12s;
|
transition: color 0.12s;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable {
|
.th.sortable {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function DataTable<T extends { id: string }>({
|
|||||||
rowAccent,
|
rowAccent,
|
||||||
expandedContent,
|
expandedContent,
|
||||||
flush = false,
|
flush = false,
|
||||||
|
fillHeight = false,
|
||||||
|
onSortChange,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||||
@@ -31,14 +33,16 @@ export function DataTable<T extends { id: string }>({
|
|||||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// When onSortChange is provided (controlled mode), skip client-side sorting
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
|
if (onSortChange) return data
|
||||||
if (!sortKey) return data
|
if (!sortKey) return data
|
||||||
return [...data].sort((a, b) => {
|
return [...data].sort((a, b) => {
|
||||||
const av = (a as Record<string, unknown>)[sortKey]
|
const av = (a as Record<string, unknown>)[sortKey]
|
||||||
const bv = (b as Record<string, unknown>)[sortKey]
|
const bv = (b as Record<string, unknown>)[sortKey]
|
||||||
return compareValues(av, bv, sortDir)
|
return compareValues(av, bv, sortDir)
|
||||||
})
|
})
|
||||||
}, [data, sortKey, sortDir])
|
}, [data, sortKey, sortDir, onSortChange])
|
||||||
|
|
||||||
const totalRows = sorted.length
|
const totalRows = sorted.length
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
||||||
@@ -52,13 +56,17 @@ export function DataTable<T extends { id: string }>({
|
|||||||
|
|
||||||
function handleHeaderClick(col: Column<T>) {
|
function handleHeaderClick(col: Column<T>) {
|
||||||
if (!sortable && !col.sortable) return
|
if (!sortable && !col.sortable) return
|
||||||
|
let newDir: SortDir
|
||||||
if (sortKey === col.key) {
|
if (sortKey === col.key) {
|
||||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
newDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||||
|
setSortDir(newDir)
|
||||||
} else {
|
} else {
|
||||||
|
newDir = 'asc'
|
||||||
setSortKey(col.key)
|
setSortKey(col.key)
|
||||||
setSortDir('asc')
|
setSortDir(newDir)
|
||||||
}
|
}
|
||||||
setPage(1)
|
setPage(1)
|
||||||
|
onSortChange?.(col.key, newDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRowClick(row: T) {
|
function handleRowClick(row: T) {
|
||||||
@@ -74,7 +82,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
|
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
|
||||||
<div className={styles.scroll}>
|
<div className={styles.scroll}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -20,4 +20,12 @@ export interface DataTableProps<T extends { id: string }> {
|
|||||||
expandedContent?: (row: T) => ReactNode | null
|
expandedContent?: (row: T) => ReactNode | null
|
||||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||||
flush?: boolean
|
flush?: boolean
|
||||||
|
/** Make the table fill remaining vertical space in a flex parent.
|
||||||
|
* The table body scrolls while the header stays sticky and the
|
||||||
|
* pagination footer stays pinned at the bottom. */
|
||||||
|
fillHeight?: boolean
|
||||||
|
/** Controlled sort: called when the user clicks a sortable column header.
|
||||||
|
* When provided, the component skips client-side sorting — the caller is
|
||||||
|
* responsible for providing `data` in the desired order. */
|
||||||
|
onSortChange?: (key: string, dir: 'asc' | 'desc') => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import styles from './DetailPanel.module.css'
|
import styles from './DetailPanel.module.css'
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
@@ -22,47 +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
|
||||||
|
|
||||||
return (
|
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
|
||||||
|
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||||
|
return portalTarget ? createPortal(content, portalTarget) : content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
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 styles from './EventFeed.module.css'
|
||||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
|
||||||
error: '\u2715', // ✕
|
error: <XIcon size={14} />,
|
||||||
warning: '\u26A0', // ⚠
|
warning: <AlertTriangle size={14} />,
|
||||||
success: '\u25B6', // ▶
|
success: <Play size={14} />,
|
||||||
running: '\u2699', // ⚙
|
running: <Loader size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
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) => 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) {
|
||||||
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<XIcon size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,10 +197,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,4 +1,5 @@
|
|||||||
import { useState, type ChangeEvent } from 'react'
|
import { useState, type ChangeEvent } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './FilterBar.module.css'
|
import styles from './FilterBar.module.css'
|
||||||
import { Input } from '../../primitives/Input/Input'
|
import { Input } from '../../primitives/Input/Input'
|
||||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||||
@@ -77,12 +78,7 @@ export function FilterBar({
|
|||||||
if (onSearchChange) onSearchChange('')
|
if (onSearchChange) onSearchChange('')
|
||||||
else setInternalSearch('')
|
else setInternalSearch('')
|
||||||
} : undefined}
|
} : undefined}
|
||||||
icon={
|
icon={<Search size={13} />}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import styles from './KpiStrip.module.css'
|
import styles from './KpiStrip.module.css'
|
||||||
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
|
||||||
export interface KpiItem {
|
export interface KpiItem {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
sparkline?: number[]
|
sparkline?: number[]
|
||||||
borderColor?: string
|
borderColor?: string
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -58,6 +56,11 @@
|
|||||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
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 {
|
.message {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const entries: LogEntry[] = [
|
|||||||
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
{ 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:10Z', level: 'error', message: 'Connection failed' },
|
||||||
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
{ 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', () => {
|
describe('LogViewer', () => {
|
||||||
@@ -16,14 +17,16 @@ describe('LogViewer', () => {
|
|||||||
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Query executed in 3ms')).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} />)
|
render(<LogViewer entries={entries} />)
|
||||||
expect(screen.getByText('INFO')).toBeInTheDocument()
|
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||||
expect(screen.getByText('WARN')).toBeInTheDocument()
|
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||||
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||||
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('TRACE')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders with custom maxHeight (number)', () => {
|
it('renders with custom maxHeight (number)', () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
|
|||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
level: 'info' | 'warn' | 'error' | 'debug'
|
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
|||||||
warn: styles.levelWarn,
|
warn: styles.levelWarn,
|
||||||
error: styles.levelError,
|
error: styles.levelError,
|
||||||
debug: styles.levelDebug,
|
debug: styles.levelDebug,
|
||||||
|
trace: styles.levelTrace,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
@@ -35,18 +36,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])
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,58 @@
|
|||||||
padding: 2px 0 2px 4px;
|
padding: 2px 0 2px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Action trigger — hidden by default, shown on hover/selected */
|
||||||
|
.actionsTrigger {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .actionsTrigger,
|
||||||
|
.actionsVisible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.1s;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './ProcessorTimeline.module.css'
|
import styles from './ProcessorTimeline.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
||||||
|
|
||||||
export interface ProcessorStep {
|
export interface ProcessorStep {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,6 +10,15 @@ export interface ProcessorStep {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
startMs: number
|
startMs: number
|
||||||
|
badges?: NodeBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessorAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessorTimelineProps {
|
interface ProcessorTimelineProps {
|
||||||
@@ -13,6 +26,8 @@ interface ProcessorTimelineProps {
|
|||||||
totalMs: number
|
totalMs: number
|
||||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: ProcessorAction[]
|
||||||
|
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +41,8 @@ export function ProcessorTimeline({
|
|||||||
totalMs,
|
totalMs,
|
||||||
onProcessorClick,
|
onProcessorClick,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
actions,
|
||||||
|
getActions,
|
||||||
className,
|
className,
|
||||||
}: ProcessorTimelineProps) {
|
}: ProcessorTimelineProps) {
|
||||||
const safeTotal = totalMs || 1
|
const safeTotal = totalMs || 1
|
||||||
@@ -70,6 +87,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
|
||||||
@@ -82,6 +109,30 @@ export function ProcessorTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
||||||
|
{(() => {
|
||||||
|
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
|
||||||
|
if (resolvedActions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${proc.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -188,17 +188,100 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottleneck badge */
|
/* Action trigger — hidden by default, shown on hover/selected */
|
||||||
.bottleneckBadge {
|
.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;
|
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); }
|
||||||
|
|||||||
160
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
160
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 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 {
|
export interface RouteNode {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,12 +15,30 @@ export interface RouteNode {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
isBottleneck?: boolean
|
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 {
|
interface RouteFlowProps {
|
||||||
nodes: RouteNode[]
|
nodes?: RouteNode[]
|
||||||
|
flows?: FlowSegment[]
|
||||||
onNodeClick?: (node: RouteNode, index: number) => void
|
onNodeClick?: (node: RouteNode, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: NodeAction[]
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +56,12 @@ function durationClass(ms: number, status: string): string {
|
|||||||
return styles.durBreach
|
return styles.durBreach
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||||
'from': '\u25B6',
|
'from': <Play size={14} />,
|
||||||
'process': '\u2699',
|
'process': <Cog size={14} />,
|
||||||
'to': '\u25A2',
|
'to': <Square size={14} />,
|
||||||
'choice': '\u25C6',
|
'choice': <Diamond size={14} />,
|
||||||
'error-handler': '\u26A0',
|
'error-handler': <AlertTriangle size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_CLASSES: Record<string, string> = {
|
const ICON_CLASSES: Record<string, string> = {
|
||||||
@@ -52,12 +79,141 @@ function nodeStatusClass(node: RouteNode): string {
|
|||||||
return styles.nodeHealthy
|
return styles.nodeHealthy
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
|
function renderActionTrigger(
|
||||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
node: RouteNode,
|
||||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
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
|
// 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
|
||||||
}, [])
|
}, [])
|
||||||
@@ -70,7 +226,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
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} />
|
||||||
@@ -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}`}>
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<div className={styles.type}>{node.type}</div>
|
<div className={styles.type}>{node.type}</div>
|
||||||
@@ -102,6 +272,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{formatDuration(node.durationMs)}
|
{formatDuration(node.durationMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -110,22 +281,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{errorHandlers.length > 0 && (
|
{errorHandlers.length > 0 && (
|
||||||
<div className={styles.errorSection}>
|
<div className={styles.errorSection}>
|
||||||
<div className={styles.errorLabel}>Error Handler</div>
|
<div className={styles.errorLabel}>Error Handler</div>
|
||||||
{errorHandlers.map((node, i) => (
|
{errorHandlers.map((node, i) => {
|
||||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
const errOriginalIndex = allNodes.indexOf(node)
|
||||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
return (
|
||||||
{TYPE_ICONS['error-handler']}
|
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||||
</div>
|
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||||
<div className={styles.info}>
|
{TYPE_ICONS['error-handler']}
|
||||||
<div className={styles.type}>{node.type}</div>
|
|
||||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.stats}>
|
|
||||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
|
||||||
{formatDuration(node.durationMs)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.type}>{node.type}</div>
|
||||||
|
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||||
|
{formatDuration(node.durationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
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', () => {
|
it('shows correct icon for success variant', () => {
|
||||||
@@ -97,7 +97,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
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', () => {
|
it('shows correct icon for warning variant', () => {
|
||||||
@@ -105,7 +105,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
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', () => {
|
it('shows correct icon for error variant', () => {
|
||||||
@@ -113,7 +113,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
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', () => {
|
it('dismisses toast when close button is clicked', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Toast.module.css'
|
import styles from './Toast.module.css'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
|
|||||||
const DEFAULT_DURATION = 5000
|
const DEFAULT_DURATION = 5000
|
||||||
const EXIT_ANIMATION_MS = 300
|
const EXIT_ANIMATION_MS = 300
|
||||||
|
|
||||||
const ICONS: Record<ToastVariant, string> = {
|
const ICONS: Record<ToastVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context ────────────────────────────────────────────────────────────────
|
// ── Context ────────────────────────────────────────────────────────────────
|
||||||
@@ -183,7 +184,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
|
|||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,12 +32,16 @@ export { MultiSelect } from './MultiSelect/MultiSelect'
|
|||||||
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||||
export { Popover } from './Popover/Popover'
|
export { Popover } from './Popover/Popover'
|
||||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
export type { RouteNode, NodeAction, 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'
|
||||||
export { Tabs } from './Tabs/Tabs'
|
export { Tabs } from './Tabs/Tabs'
|
||||||
export { ToastProvider, useToast } from './Toast/Toast'
|
export { ToastProvider, useToast } from './Toast/Toast'
|
||||||
export { TreeView } from './TreeView/TreeView'
|
export { TreeView } from './TreeView/TreeView'
|
||||||
|
|
||||||
|
// Chart utilities for consumers using Recharts or custom charts
|
||||||
|
export { CHART_COLORS } from './_chart-utils'
|
||||||
|
export type { ChartSeries, DataPoint } from './_chart-utils'
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ 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'
|
||||||
|
export * from './utils/rechartsTheme'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
|
|||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
sidebar: ReactNode
|
sidebar: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
|
||||||
detail?: ReactNode
|
detail?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ sidebar, children, detail }: AppShellProps) {
|
export function AppShell({ sidebar, children }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{detail}
|
<div id="cameleer-detail-panel-root" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||||
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
||||||
@@ -33,6 +34,7 @@ export interface SidebarAgent {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
apps: SidebarApp[]
|
apps: SidebarApp[]
|
||||||
className?: string
|
className?: string
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -55,7 +57,7 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|||||||
id: `route:${app.id}:${route.id}`,
|
id: `route:${app.id}:${route.id}`,
|
||||||
starKey: `${app.id}:${route.id}`,
|
starKey: `${app.id}:${route.id}`,
|
||||||
label: route.name,
|
label: route.name,
|
||||||
icon: <span className={styles.routeArrow}>▸</span>,
|
icon: <ChevronRight size={12} />,
|
||||||
badge: formatCount(route.exchangeCount),
|
badge: formatCount(route.exchangeCount),
|
||||||
path: `/apps/${app.id}/${route.id}`,
|
path: `/apps/${app.id}/${route.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
@@ -78,7 +80,7 @@ function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|||||||
id: `routestat:${app.id}:${route.id}`,
|
id: `routestat:${app.id}:${route.id}`,
|
||||||
starKey: `routes:${app.id}:${route.id}`,
|
starKey: `routes:${app.id}:${route.id}`,
|
||||||
label: route.name,
|
label: route.name,
|
||||||
icon: <span className={styles.routeArrow}>▸</span>,
|
icon: <ChevronRight size={12} />,
|
||||||
badge: formatCount(route.exchangeCount),
|
badge: formatCount(route.exchangeCount),
|
||||||
path: `/routes/${app.id}/${route.id}`,
|
path: `/routes/${app.id}/${route.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
@@ -235,10 +237,7 @@ function StarredGroup({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={`Remove ${item.label} from starred`}
|
aria-label={`Remove ${item.label} from starred`}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<X size={12} />
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -248,7 +247,7 @@ function StarredGroup({
|
|||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Sidebar({ apps, className }: SidebarProps) {
|
export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||||
@@ -277,7 +276,8 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const nav = onNavigate ?? routerNavigate
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
|
<div className={styles.logo} onClick={() => nav('/apps')} style={{ cursor: 'pointer' }}>
|
||||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
||||||
<div>
|
<div>
|
||||||
<span className={styles.brand}>cameleer</span>
|
<span className={styles.brand}>cameleer</span>
|
||||||
@@ -344,10 +344,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<div className={styles.searchWrap}>
|
<div className={styles.searchWrap}>
|
||||||
<div className={styles.searchInner}>
|
<div className={styles.searchInner}>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
<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">
|
<Search size={12} />
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className={styles.searchInput}
|
className={styles.searchInput}
|
||||||
@@ -363,7 +360,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,14 +379,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!appsCollapsed}
|
aria-expanded={!appsCollapsed}
|
||||||
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
||||||
>
|
>
|
||||||
{appsCollapsed ? '▸' : '▾'}
|
{appsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/apps')}
|
onClick={() => nav('/apps')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
|
||||||
>
|
>
|
||||||
Applications
|
Applications
|
||||||
</span>
|
</span>
|
||||||
@@ -403,6 +400,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:apps"
|
persistKey="cameleer:expanded:apps"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -416,14 +414,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!agentsCollapsed}
|
aria-expanded={!agentsCollapsed}
|
||||||
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
||||||
>
|
>
|
||||||
{agentsCollapsed ? '▸' : '▾'}
|
{agentsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/agents')}
|
onClick={() => nav('/agents')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
|
||||||
>
|
>
|
||||||
Agents
|
Agents
|
||||||
</span>
|
</span>
|
||||||
@@ -437,6 +435,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:agents"
|
persistKey="cameleer:expanded:agents"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -450,14 +449,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!routesCollapsed}
|
aria-expanded={!routesCollapsed}
|
||||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||||
>
|
>
|
||||||
{routesCollapsed ? '▸' : '▾'}
|
{routesCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/routes')}
|
onClick={() => nav('/routes')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
|
||||||
>
|
>
|
||||||
Routes
|
Routes
|
||||||
</span>
|
</span>
|
||||||
@@ -471,6 +470,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:routes"
|
persistKey="cameleer:expanded:routes"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -491,7 +491,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Applications"
|
label="Applications"
|
||||||
items={starredApps}
|
items={starredApps}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -499,7 +499,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Routes"
|
label="Routes"
|
||||||
items={starredRoutes}
|
items={starredRoutes}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -507,7 +507,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Agents"
|
label="Agents"
|
||||||
items={starredAgents}
|
items={starredAgents}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -515,7 +515,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Routes"
|
label="Routes"
|
||||||
items={starredRouteStats}
|
items={starredRouteStats}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -531,12 +531,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => nav('/admin')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/admin') }}
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}>⚙</span>
|
<span className={styles.bottomIcon}><Settings size={14} /></span>
|
||||||
<div className={styles.itemInfo}>
|
<div className={styles.itemInfo}>
|
||||||
<div className={styles.itemName}>Admin</div>
|
<div className={styles.itemName}>Admin</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -546,12 +546,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/api-docs')}
|
onClick={() => nav('/api-docs')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/api-docs') }}
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}>☰</span>
|
<span className={styles.bottomIcon}><FileText size={14} /></span>
|
||||||
<div className={styles.itemInfo}>
|
<div className={styles.itemInfo}>
|
||||||
<div className={styles.itemName}>API Docs</div>
|
<div className={styles.itemName}>API Docs</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -33,24 +34,17 @@ export interface SidebarTreeProps {
|
|||||||
filterQuery?: string
|
filterQuery?: string
|
||||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||||
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
// ── Star icons ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StarOutline() {
|
function StarOutline() {
|
||||||
return (
|
return <Star size={14} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarFilled() {
|
function StarFilled() {
|
||||||
return (
|
return <Star size={14} fill="currentColor" />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistent expand state ──────────────────────────────────────────────────
|
// ── Persistent expand state ──────────────────────────────────────────────────
|
||||||
@@ -141,8 +135,10 @@ export function SidebarTree({
|
|||||||
filterQuery,
|
filterQuery,
|
||||||
persistKey,
|
persistKey,
|
||||||
autoRevealPath,
|
autoRevealPath,
|
||||||
|
onNavigate,
|
||||||
}: SidebarTreeProps) {
|
}: SidebarTreeProps) {
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const navigate = onNavigate ?? routerNavigate
|
||||||
|
|
||||||
// Expand/collapse state — optionally persisted to sessionStorage
|
// Expand/collapse state — optionally persisted to sessionStorage
|
||||||
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
||||||
@@ -395,7 +391,7 @@ function SidebarTreeRow({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
>
|
>
|
||||||
{isExpanded ? '▾' : '▸'}
|
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -81,6 +81,56 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.liveToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveToggle:hover {
|
||||||
|
border-color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveToggleActive {
|
||||||
|
color: var(--success);
|
||||||
|
border-color: var(--success-border);
|
||||||
|
background: var(--success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveToggleActive:hover {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveToggleActive .liveDot {
|
||||||
|
background: var(--success);
|
||||||
|
animation: livePulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes livePulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
.themeToggle {
|
.themeToggle {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Search, Moon, Sun, Power } from 'lucide-react'
|
||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||||
@@ -8,11 +9,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 +37,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
|
||||||
@@ -53,10 +52,7 @@ export function TopBar({
|
|||||||
aria-label="Open search"
|
aria-label="Open search"
|
||||||
>
|
>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
<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">
|
<Search size={13} />
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||||
<span className={styles.kbd}>Ctrl+K</span>
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
@@ -84,8 +80,18 @@ export function TopBar({
|
|||||||
onChange={globalFilters.setTimeRange}
|
onChange={globalFilters.setTimeRange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right: theme toggle, env badge, user */}
|
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
|
<button
|
||||||
|
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
|
||||||
|
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
|
||||||
|
type="button"
|
||||||
|
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
|
||||||
|
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||||
|
>
|
||||||
|
<span className={styles.liveDot} />
|
||||||
|
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.themeToggle}
|
className={styles.themeToggle}
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
@@ -93,7 +99,7 @@ export function TopBar({
|
|||||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
title={`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>
|
</button>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<span className={styles.env}>{environment}</span>
|
||||||
@@ -107,7 +113,7 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
|
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,24 +46,23 @@ describe('Alert', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows default icon for each variant', () => {
|
it('shows default icon for each variant', () => {
|
||||||
const { rerender } = render(<Alert variant="info">msg</Alert>)
|
const { container, rerender } = render(<Alert variant="info">msg</Alert>)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
// 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>)
|
rerender(<Alert variant="success">msg</Alert>)
|
||||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="warning">msg</Alert>)
|
rerender(<Alert variant="warning">msg</Alert>)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="error">msg</Alert>)
|
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', () => {
|
it('renders a custom icon when provided', () => {
|
||||||
render(<Alert icon={<span>★</span>}>Custom icon alert</Alert>)
|
render(<Alert icon={<span data-testid="custom-icon">★</span>}>Custom icon alert</Alert>)
|
||||||
expect(screen.getByText('★')).toBeInTheDocument()
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||||
// Default icon should not appear
|
|
||||||
expect(screen.queryByText('ℹ')).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show dismiss button when dismissible is false', () => {
|
it('does not show dismiss button when dismissible is false', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Alert.module.css'
|
import styles from './Alert.module.css'
|
||||||
|
|
||||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||||
@@ -13,11 +14,11 @@ interface AlertProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<AlertVariant, string> = {
|
const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
||||||
@@ -61,7 +62,7 @@ export function Alert({
|
|||||||
aria-label="Dismiss alert"
|
aria-label="Dismiss alert"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import styles from './Input.module.css'
|
import styles from './Input.module.css'
|
||||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 {
|
||||||
@@ -16,6 +16,8 @@ interface GlobalFilterContextValue {
|
|||||||
toggleStatus: (status: ExchangeStatus) => void
|
toggleStatus: (status: ExchangeStatus) => void
|
||||||
clearStatusFilters: () => void
|
clearStatusFilters: () => void
|
||||||
isInTimeRange: (timestamp: Date) => boolean
|
isInTimeRange: (timestamp: Date) => boolean
|
||||||
|
autoRefresh: boolean
|
||||||
|
setAutoRefresh: (enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||||
@@ -27,9 +29,17 @@ function getDefaultTimeRange(): TimeRange {
|
|||||||
return { start, end, preset: DEFAULT_PRESET }
|
return { start, end, preset: DEFAULT_PRESET }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialAutoRefresh(): boolean {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('cameleer:auto-refresh')
|
||||||
|
return stored === null ? true : stored === 'true'
|
||||||
|
} catch { return true }
|
||||||
|
}
|
||||||
|
|
||||||
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||||
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
||||||
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
||||||
|
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
|
||||||
|
|
||||||
const setTimeRange = useCallback((range: TimeRange) => {
|
const setTimeRange = useCallback((range: TimeRange) => {
|
||||||
setTimeRangeState(range)
|
setTimeRangeState(range)
|
||||||
@@ -51,6 +61,21 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
setStatusFilters(new Set())
|
setStatusFilters(new Set())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setAutoRefresh = useCallback((enabled: boolean) => {
|
||||||
|
setAutoRefreshState(enabled)
|
||||||
|
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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) {
|
||||||
@@ -65,7 +90,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalFilterContext.Provider
|
<GlobalFilterContext.Provider
|
||||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
|
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</GlobalFilterContext.Provider>
|
</GlobalFilterContext.Provider>
|
||||||
|
|||||||
71
src/design-system/utils/rechartsTheme.ts
Normal file
71
src/design-system/utils/rechartsTheme.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { CHART_COLORS } from '../composites/_chart-utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-configured Recharts prop objects that match the design system's
|
||||||
|
* chart styling. Spread these onto Recharts sub-components:
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
* import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend } from 'recharts'
|
||||||
|
*
|
||||||
|
* <LineChart data={data}>
|
||||||
|
* <CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
* <XAxis dataKey="name" {...rechartsTheme.xAxis} />
|
||||||
|
* <YAxis {...rechartsTheme.yAxis} />
|
||||||
|
* <Tooltip {...rechartsTheme.tooltip} />
|
||||||
|
* <Legend {...rechartsTheme.legend} />
|
||||||
|
* <Line stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
|
||||||
|
* </LineChart>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const rechartsTheme = {
|
||||||
|
colors: CHART_COLORS,
|
||||||
|
|
||||||
|
cartesianGrid: {
|
||||||
|
stroke: 'var(--border-subtle)',
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
vertical: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
xAxis: {
|
||||||
|
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
|
||||||
|
axisLine: { stroke: 'var(--border-subtle)' },
|
||||||
|
tickLine: false as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
yAxis: {
|
||||||
|
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
|
||||||
|
axisLine: false as const,
|
||||||
|
tickLine: false as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
tooltip: {
|
||||||
|
contentStyle: {
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '6px 10px',
|
||||||
|
},
|
||||||
|
labelStyle: {
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
cursor: { stroke: 'var(--text-faint)' },
|
||||||
|
},
|
||||||
|
|
||||||
|
legend: {
|
||||||
|
wrapperStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -313,3 +313,74 @@ export const exchanges: Exchange[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Generate additional exchanges to reach ~200 total ────────────────────────
|
||||||
|
|
||||||
|
const GEN_ROUTES = [
|
||||||
|
'order-intake', 'payment-validate', 'order-enrichment', 'shipment-dispatch',
|
||||||
|
'payment-process', 'shipment-track', 'inventory-sync', 'notification-send',
|
||||||
|
'audit-log', 'customer-update', 'refund-process', 'invoice-generate',
|
||||||
|
]
|
||||||
|
const GEN_ROUTE_GROUPS = [
|
||||||
|
'order-flow', 'payment-flow', 'shipment-flow', 'inventory-flow',
|
||||||
|
'notification-flow', 'audit-flow', 'customer-flow', 'billing-flow',
|
||||||
|
]
|
||||||
|
const GEN_AGENTS = ['prod-1', 'prod-2', 'prod-3', 'prod-4']
|
||||||
|
const GEN_STATUSES: Exchange['status'][] = [
|
||||||
|
'completed', 'completed', 'completed', 'completed', 'completed',
|
||||||
|
'completed', 'failed', 'warning', 'running',
|
||||||
|
]
|
||||||
|
const GEN_PROCESSORS: ProcessorData[][] = [
|
||||||
|
[
|
||||||
|
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||||
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
|
||||||
|
{ name: 'validate(schema)', type: 'process', durationMs: 10, status: 'ok', startMs: 10 },
|
||||||
|
{ name: 'to(target)', type: 'to', durationMs: 15, status: 'ok', startMs: 20 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||||
|
{ name: 'enrich(external-api)', type: 'enrich', durationMs: 120, status: 'slow', startMs: 5 },
|
||||||
|
{ name: 'to(target)', type: 'to', durationMs: 20, status: 'ok', startMs: 125 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
||||||
|
{ name: 'to(external-gateway)', type: 'to', durationMs: 400, status: 'fail', startMs: 3 },
|
||||||
|
],
|
||||||
|
]
|
||||||
|
const GEN_ERRORS = [
|
||||||
|
{ msg: 'org.apache.camel.CamelExecutionException: Timeout after 5000ms', cls: 'org.apache.camel.CamelExecutionException' },
|
||||||
|
{ msg: 'java.sql.SQLTransientConnectionException: Connection pool exhausted', cls: 'java.sql.SQLTransientConnectionException' },
|
||||||
|
{ msg: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP 502 Bad Gateway', cls: 'org.apache.camel.component.http.HttpOperationFailedException' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BASE_TIME = new Date('2026-03-18T08:00:00').getTime()
|
||||||
|
|
||||||
|
for (let i = 0; i < 185; i++) {
|
||||||
|
const idx = exchanges.length
|
||||||
|
const status = GEN_STATUSES[i % GEN_STATUSES.length]
|
||||||
|
const route = GEN_ROUTES[i % GEN_ROUTES.length]
|
||||||
|
const routeGroup = GEN_ROUTE_GROUPS[i % GEN_ROUTE_GROUPS.length]
|
||||||
|
const isFailed = status === 'failed'
|
||||||
|
const durationMs = isFailed
|
||||||
|
? 1000 + ((i * 37) % 4000)
|
||||||
|
: status === 'running'
|
||||||
|
? 10000 + ((i * 53) % 20000)
|
||||||
|
: 30 + ((i * 73) % 400)
|
||||||
|
const err = isFailed ? GEN_ERRORS[i % GEN_ERRORS.length] : undefined
|
||||||
|
|
||||||
|
exchanges.push({
|
||||||
|
id: `E-2026-03-18-${String(idx + 200).padStart(5, '0')}`,
|
||||||
|
orderId: `OP-${80000 + idx}`,
|
||||||
|
customer: `CUST-${10000 + ((i * 131) % 90000)}`,
|
||||||
|
route,
|
||||||
|
routeGroup,
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
timestamp: new Date(BASE_TIME - i * 2 * 60 * 1000),
|
||||||
|
correlationId: `cmr-${i.toString(16).padStart(8, '0')}-gen`,
|
||||||
|
agent: GEN_AGENTS[i % GEN_AGENTS.length],
|
||||||
|
...(err ? { errorMessage: err.msg, errorClass: err.cls } : {}),
|
||||||
|
processors: GEN_PROCESSORS[i % GEN_PROCESSORS.length].map((p) => ({ ...p })),
|
||||||
|
...(i % 3 === 0 ? { correlationGroup: `${routeGroup}-${String(Math.floor(i / 3)).padStart(3, '0')}` } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
import styles from './AgentHealth.module.css'
|
import styles from './AgentHealth.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -389,7 +390,7 @@ export function AgentHealth() {
|
|||||||
{scope.level !== 'all' && (
|
{scope.level !== 'all' && (
|
||||||
<>
|
<>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
import styles from './AgentInstance.module.css'
|
import styles from './AgentInstance.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -177,9 +178,9 @@ export function AgentInstance() {
|
|||||||
{/* Scope trail + badges */}
|
{/* Scope trail + badges */}
|
||||||
<div className={styles.scopeTrail}>
|
<div className={styles.scopeTrail}>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||||
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
||||||
<Badge label={agent.version} color="auto" variant="outlined" />
|
<Badge label={agent.version} color="auto" variant="outlined" />
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px 24px 40px;
|
padding: 20px 24px 40px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filter bar spacing */
|
/* Filter bar spacing */
|
||||||
@@ -19,6 +22,10 @@
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableHeader {
|
.tableHeader {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -43,10 +44,10 @@ const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
|
|||||||
warning: 'var(--warning)',
|
warning: 'var(--warning)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TREND_ICONS: Record<KpiMetric['trend'], string> = {
|
const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
|
||||||
up: '\u2191',
|
up: <TrendingUp size={12} />,
|
||||||
down: '\u2193',
|
down: <TrendingDown size={12} />,
|
||||||
neutral: '\u2192',
|
neutral: <ArrowRight size={12} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
||||||
@@ -60,7 +61,7 @@ function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' |
|
|||||||
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
||||||
label: m.label,
|
label: m.label,
|
||||||
value: m.unit ? `${m.value} ${m.unit}` : m.value,
|
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,
|
subtitle: m.detail,
|
||||||
sparkline: m.sparkline,
|
sparkline: m.sparkline,
|
||||||
borderColor: ACCENT_TO_COLOR[m.accent],
|
borderColor: ACCENT_TO_COLOR[m.accent],
|
||||||
@@ -206,7 +207,7 @@ export function Dashboard() {
|
|||||||
navigate(`/exchanges/${row.id}`)
|
navigate(`/exchanges/${row.id}`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↗
|
<ExternalLink size={14} />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -303,7 +304,7 @@ export function Dashboard() {
|
|||||||
className={styles.openDetailLink}
|
className={styles.openDetailLink}
|
||||||
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
||||||
>
|
>
|
||||||
Open full details →
|
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -424,11 +425,12 @@ export function Dashboard() {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
sortable
|
sortable
|
||||||
flush
|
flush
|
||||||
|
fillHeight
|
||||||
rowAccent={handleRowAccent}
|
rowAccent={handleRowAccent}
|
||||||
expandedContent={(row) =>
|
expandedContent={(row) =>
|
||||||
row.errorMessage ? (
|
row.errorMessage ? (
|
||||||
<div className={styles.inlineError}>
|
<div className={styles.inlineError}>
|
||||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
||||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Hexagon, ArrowRight, Diamond, Eye, Pencil, RotateCcw, Trash2, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CompositesSection.module.css'
|
import styles from './CompositesSection.module.css'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -134,13 +135,27 @@ interface TableRow {
|
|||||||
exchanges: number
|
exchanges: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABLE_DATA: TableRow[] = [
|
const ROUTE_PREFIXES = [
|
||||||
{ id: '1', name: 'order-ingest', method: 'POST', status: 'live', exchanges: 1243 },
|
'order', 'payment', 'inventory', 'customer', 'shipment', 'user', 'cart',
|
||||||
{ id: '2', name: 'payment-validate', method: 'POST', status: 'live', exchanges: 987 },
|
'refund', 'stock', 'email', 'webhook', 'cache', 'log', 'rate', 'health',
|
||||||
{ id: '3', name: 'inventory-check', method: 'GET', status: 'stale', exchanges: 432 },
|
'session', 'config', 'metrics', 'batch', 'audit', 'invoice', 'product',
|
||||||
{ id: '4', name: 'notify-customer', method: 'POST', status: 'live', exchanges: 876 },
|
'catalog', 'search', 'report', 'export', 'import', 'sync', 'alert', 'ticket',
|
||||||
{ id: '5', name: 'archive-order', method: 'PUT', status: 'dead', exchanges: 54 },
|
|
||||||
]
|
]
|
||||||
|
const ROUTE_SUFFIXES = [
|
||||||
|
'ingest', 'validate', 'check', 'notify', 'archive', 'track', 'auth',
|
||||||
|
'update', 'process', 'sync', 'dispatch', 'relay', 'invalidate', 'aggregate',
|
||||||
|
'limit', 'refresh', 'reload', 'export', 'import', 'purge',
|
||||||
|
]
|
||||||
|
const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
|
||||||
|
const STATUSES = ['live', 'live', 'live', 'live', 'stale', 'stale', 'dead']
|
||||||
|
|
||||||
|
const TABLE_DATA: TableRow[] = Array.from({ length: 500 }, (_, i) => ({
|
||||||
|
id: String(i + 1),
|
||||||
|
name: `${ROUTE_PREFIXES[i % ROUTE_PREFIXES.length]}-${ROUTE_SUFFIXES[(i * 7) % ROUTE_SUFFIXES.length]}`,
|
||||||
|
method: METHODS[i % METHODS.length],
|
||||||
|
status: STATUSES[i % STATUSES.length],
|
||||||
|
exchanges: ((i * 1337 + 421) % 9990) + 10,
|
||||||
|
}))
|
||||||
|
|
||||||
const NOW = new Date()
|
const NOW = new Date()
|
||||||
const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
|
const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
|
||||||
@@ -157,25 +172,25 @@ const TREE_NODES = [
|
|||||||
{
|
{
|
||||||
id: 'app1',
|
id: 'app1',
|
||||||
label: 'cameleer-prod',
|
label: 'cameleer-prod',
|
||||||
icon: '⬡',
|
icon: <Hexagon size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'route1',
|
id: 'route1',
|
||||||
label: 'order-ingest',
|
label: 'order-ingest',
|
||||||
icon: '→',
|
icon: <ArrowRight size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' },
|
{ id: 'proc1', label: 'ValidateOrder', icon: <Diamond size={12} />, meta: '12ms' },
|
||||||
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' },
|
{ id: 'proc2', label: 'EnrichPayload', icon: <Diamond size={12} />, meta: '8ms' },
|
||||||
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' },
|
{ id: 'proc3', label: 'RouteToQueue', icon: <Diamond size={12} />, meta: '3ms' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'route2',
|
id: 'route2',
|
||||||
label: 'payment-validate',
|
label: 'payment-validate',
|
||||||
icon: '→',
|
icon: <ArrowRight size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' },
|
{ id: 'proc4', label: 'TokenizeCard', icon: <Diamond size={12} />, meta: '22ms' },
|
||||||
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' },
|
{ id: 'proc5', label: 'AuthorizePayment', icon: <Diamond size={12} />, meta: '45ms' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -467,12 +482,13 @@ export function CompositesSection() {
|
|||||||
title="DataTable"
|
title="DataTable"
|
||||||
description="Sortable, paginated table with row click, accent rows, and page size selector."
|
description="Sortable, paginated table with row click, accent rows, and page size selector."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%', height: 500, display: 'flex', flexDirection: 'column' }}>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
data={TABLE_DATA}
|
data={TABLE_DATA}
|
||||||
sortable
|
sortable
|
||||||
pageSize={5}
|
pageSize={25}
|
||||||
|
fillHeight
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -505,13 +521,13 @@ export function CompositesSection() {
|
|||||||
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
||||||
>
|
>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={<Button size="sm" variant="secondary">Actions ▾</Button>}
|
trigger={<Button size="sm" variant="secondary">Actions <ChevronDown size={12} /></Button>}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'View details', icon: '👁', onClick: () => undefined },
|
{ label: 'View details', icon: <Eye size={14} />, onClick: () => undefined },
|
||||||
{ label: 'Edit route', icon: '✏', onClick: () => undefined },
|
{ label: 'Edit route', icon: <Pencil size={14} />, onClick: () => undefined },
|
||||||
{ divider: true, label: '' },
|
{ divider: true, label: '' },
|
||||||
{ label: 'Restart', icon: '↺', onClick: () => undefined },
|
{ label: 'Restart', icon: <RotateCcw size={14} />, onClick: () => undefined },
|
||||||
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true },
|
{ label: 'Delete', icon: <Trash2 size={14} />, onClick: () => undefined, disabled: true },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -769,7 +785,7 @@ export function CompositesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="processortimeline"
|
id="processortimeline"
|
||||||
title="ProcessorTimeline"
|
title="ProcessorTimeline"
|
||||||
description="Horizontal Gantt-style timeline showing processor execution order, duration, and status."
|
description="Horizontal Gantt-style timeline with selectable rows and optional action menus via actions or getActions prop."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<ProcessorTimeline
|
<ProcessorTimeline
|
||||||
@@ -780,6 +796,11 @@ export function CompositesSection() {
|
|||||||
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
||||||
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
||||||
]}
|
]}
|
||||||
|
getActions={(proc) => [
|
||||||
|
{ label: 'Change Log Level', onClick: () => {} },
|
||||||
|
{ label: 'View Configuration', onClick: () => {} },
|
||||||
|
...(proc.status === 'fail' ? [{ label: 'View Stack Trace', onClick: () => {} }] : []),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -788,7 +809,7 @@ export function CompositesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="routeflow"
|
id="routeflow"
|
||||||
title="RouteFlow"
|
title="RouteFlow"
|
||||||
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
|
description="Vertical processor node diagram with status coloring, connectors, and optional action menus."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
<RouteFlow
|
<RouteFlow
|
||||||
@@ -802,6 +823,41 @@ export function CompositesSection() {
|
|||||||
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
||||||
]}
|
]}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Change Log Level', onClick: () => {} },
|
||||||
|
{ label: 'Enable Tracing', onClick: () => {} },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './PrimitivesSection.module.css'
|
import styles from './PrimitivesSection.module.css'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -358,7 +359,7 @@ export function PrimitivesSection() {
|
|||||||
description="Text input with optional leading icon and placeholder."
|
description="Text input with optional leading icon and placeholder."
|
||||||
>
|
>
|
||||||
<Input placeholder="Plain input" />
|
<Input placeholder="Plain input" />
|
||||||
<Input icon="🔍" placeholder="With icon" />
|
<Input icon={<Search size={14} />} placeholder="With icon" />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 15b. InlineEdit */}
|
{/* 15b. InlineEdit */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import styles from './RouteDetail.module.css'
|
import styles from './RouteDetail.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -337,7 +338,7 @@ export function RouteDetail() {
|
|||||||
expandedContent={(row) =>
|
expandedContent={(row) =>
|
||||||
row.errorMessage ? (
|
row.errorMessage ? (
|
||||||
<div className={styles.inlineError}>
|
<div className={styles.inlineError}>
|
||||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.errorClass}>{row.errorClass}</div>
|
<div className={styles.errorClass}>{row.errorClass}</div>
|
||||||
<div className={styles.errorText}>{row.errorMessage}</div>
|
<div className={styles.errorText}>{row.errorMessage}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user