Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4841a7ad7c | ||
|
|
32a49690fa | ||
|
|
20f7b2f5aa | ||
|
|
5cb51e65be | ||
|
|
4dcd4aaa27 | ||
|
|
58320b9762 | ||
|
|
c48dffaef2 | ||
|
|
3ef4c5686e | ||
|
|
78e28789a5 | ||
|
|
03ec34bb5c | ||
|
|
2f1df869db | ||
|
|
0cf696cded | ||
|
|
50a1296a9d | ||
|
|
9b8739b5d8 | ||
|
|
ba6028c2ea | ||
|
|
93776944b9 | ||
|
|
9240acddb6 | ||
|
|
912adb1070 | ||
|
|
eeb2713612 | ||
|
|
18bf644040 | ||
|
|
9fa7eb129d | ||
|
|
8cd3c3a99d | ||
|
|
36999941c0 | ||
|
|
5a91875723 | ||
|
|
c401516b2d | ||
|
|
d2c2b92183 | ||
|
|
357e497220 | ||
|
|
1173b3e363 | ||
|
|
7092271fdc | ||
|
|
3561147b42 | ||
|
|
9afe626a58 | ||
|
|
7758962564 | ||
|
|
4e2d5b2b2f | ||
|
|
af48bd2fa0 | ||
|
|
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 |
@@ -15,7 +15,21 @@
|
||||
"Bash(echo \"EXIT:$?\")",
|
||||
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||
"Bash(echo \"EXIT_CODE=$?\")",
|
||||
"Bash(echo \"EXIT=$?\")"
|
||||
"Bash(echo \"EXIT=$?\")",
|
||||
"mcp__gitea__actions_config_read",
|
||||
"mcp__gitea__search_repos",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(cd:*)",
|
||||
"mcp__gitea__actions_run_read",
|
||||
"mcp__gitea__get_file_contents",
|
||||
"WebFetch(domain:ui.shadcn.com)",
|
||||
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(xargs cat:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
run: npx vitest run --exclude 'e2e/**'
|
||||
|
||||
- name: Build library
|
||||
run: npm run build:lib
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
TAG="latest"
|
||||
;;
|
||||
*)
|
||||
|
||||
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
@@ -2,3 +2,6 @@ node_modules/
|
||||
dist/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
test-results/
|
||||
screenshots/
|
||||
.playwright-mcp/
|
||||
|
||||
20
CLAUDE.md
@@ -40,6 +40,10 @@ import { Button, Input } from '../design-system/primitives'
|
||||
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
|
||||
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
|
||||
import { AppShell } from '../design-system/layout/AppShell'
|
||||
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
|
||||
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
|
||||
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
|
||||
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
|
||||
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||
```
|
||||
|
||||
@@ -93,6 +97,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||
// All components from single entry
|
||||
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
|
||||
|
||||
// Sidebar (compound component — compose your own navigation)
|
||||
import { Sidebar, SidebarTree, useStarred } from '@cameleer/design-system'
|
||||
import type { SidebarTreeNode } from '@cameleer/design-system'
|
||||
|
||||
// Types
|
||||
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
||||
|
||||
@@ -104,6 +112,18 @@ import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
|
||||
// Utils
|
||||
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)
|
||||
import '@cameleer/design-system/style.css'
|
||||
|
||||
// Brand assets (static files via ./assets/* export)
|
||||
import logo from '@cameleer/design-system/assets/cameleer3-logo.png' // full resolution
|
||||
import logo32 from '@cameleer/design-system/assets/cameleer3-32.png' // 32×32 favicon
|
||||
import logo180 from '@cameleer/design-system/assets/cameleer3-180.png' // Apple touch icon
|
||||
import logo192 from '@cameleer/design-system/assets/cameleer3-192.png' // Android/PWA icon
|
||||
import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA splash, og:image
|
||||
import logoSvg from '@cameleer/design-system/assets/camel-logo.svg' // SVG vector logo
|
||||
```
|
||||
|
||||
@@ -38,10 +38,12 @@
|
||||
- Removable label → **Tag**
|
||||
|
||||
### "I need navigation"
|
||||
- App-level sidebar nav → **Sidebar** (via AppShell) — hierarchical trees with starring
|
||||
- App-level sidebar nav → **Sidebar** (compound component — compose sections, trees, footer links)
|
||||
- Sidebar tree section → **SidebarTree** (data-driven tree with expand/collapse, starring, keyboard nav)
|
||||
- Starred items persistence → **useStarred** hook (localStorage-backed)
|
||||
- Breadcrumb trail → **Breadcrumb**
|
||||
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
|
||||
- Hierarchical tree navigation → **TreeView** (generic) or **SidebarTree** (sidebar-specific, internal)
|
||||
- Hierarchical tree navigation → **TreeView** (generic)
|
||||
|
||||
### "I need floating content"
|
||||
- Tooltip on hover → **Tooltip**
|
||||
@@ -55,6 +57,7 @@
|
||||
- Time series → **LineChart**, **AreaChart**
|
||||
- Categorical comparison → **BarChart**
|
||||
- Inline trend → **Sparkline**
|
||||
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
|
||||
- Event log → **EventFeed**
|
||||
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||
- Processing pipeline (flow diagram) → **RouteFlow**
|
||||
@@ -98,7 +101,24 @@
|
||||
|
||||
### Standard page layout
|
||||
```
|
||||
AppShell → Sidebar + TopBar + main content + optional DetailPanel
|
||||
AppShell → Sidebar (compound) + TopBar + main content + optional DetailPanel
|
||||
|
||||
Sidebar compound API:
|
||||
<Sidebar collapsed={bool} onCollapseToggle={fn} searchValue={str} onSearchChange={fn}>
|
||||
<Sidebar.Header logo={node} title="str" version="str" />
|
||||
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} active={bool}>
|
||||
<SidebarTree nodes={[...]} selectedPath="..." filterQuery="..." ... />
|
||||
</Sidebar.Section>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={node} label="str" onClick={fn} active={bool} />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
|
||||
Notes:
|
||||
- Search input auto-renders between Header and first Section (not above Header)
|
||||
- Section headers have no chevron — the entire row is clickable to toggle
|
||||
- The app controls all content — sections, order, tree data, collapse state
|
||||
- Sidebar provides the frame, search input, and icon-rail collapse mode
|
||||
```
|
||||
|
||||
### Data page pattern
|
||||
@@ -160,6 +180,53 @@ StatCard strip (top, recalculates per scope)
|
||||
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 | Layer | When to use |
|
||||
@@ -179,7 +246,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
||||
| Checkbox | primitive | Boolean input with label |
|
||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||
| Collapsible | primitive | Single expand/collapse section |
|
||||
| CommandPalette | composite | Full-screen search and command interface |
|
||||
| CommandPalette | composite | Full-screen search and command interface. `SearchCategory` is an open `string` type — known categories (application, exchange, attribute, route, agent) have built-in labels; custom categories render with title-cased labels and appear as dynamic tabs. |
|
||||
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||
| DateRangePicker | primitive | Date range selection with presets |
|
||||
@@ -207,8 +274,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||
| Pagination | primitive | Page navigation controls |
|
||||
| Popover | composite | Click-triggered floating panel with arrow |
|
||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. |
|
||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. |
|
||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||
@@ -236,8 +303,10 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
||||
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
||||
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
|
||||
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
|
||||
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
|
||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
|
||||
|
||||
## Import Paths
|
||||
|
||||
@@ -248,6 +317,10 @@ import { Button, Input, Badge } from './design-system/primitives'
|
||||
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||
import { AppShell } from './design-system/layout/AppShell'
|
||||
import { Sidebar } from './design-system/layout/Sidebar/Sidebar'
|
||||
import { SidebarTree } from './design-system/layout/Sidebar/SidebarTree'
|
||||
import type { SidebarTreeNode } from './design-system/layout/Sidebar/SidebarTree'
|
||||
import { useStarred } from './design-system/layout/Sidebar/useStarred'
|
||||
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||
```
|
||||
|
||||
@@ -261,6 +334,34 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
|
||||
|
||||
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||
|
||||
## Brand Assets
|
||||
|
||||
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
|
||||
|
||||
| File | Size | Use case |
|
||||
|------|------|----------|
|
||||
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
|
||||
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
|
||||
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
|
||||
| `cameleer3-48.png` | 48×48 | Windows taskbar |
|
||||
| `cameleer3-180.png` | 180×180 | Apple touch icon |
|
||||
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
|
||||
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
|
||||
| `camel-logo.svg` | Vector | SVG logo for scalable usage |
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import logo from '@cameleer/design-system/assets/cameleer3-512.png'
|
||||
<img src={logo} alt="Cameleer3" />
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Favicons in index.html -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/cameleer3-32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/cameleer3-180.png">
|
||||
```
|
||||
|
||||
## Styling Rules
|
||||
|
||||
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
||||
|
||||
3
assets/camel-logo.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/cameleer3-16.png
Normal file
|
After Width: | Height: | Size: 983 B |
BIN
assets/cameleer3-180.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/cameleer3-192.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
assets/cameleer3-32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/cameleer3-48.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
assets/cameleer3-512.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 374 KiB |
1609
docs/superpowers/plans/2026-04-02-composable-sidebar.md
Normal file
399
docs/superpowers/specs/2026-04-02-composable-sidebar-design.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Composable Sidebar Refactor
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**Upstream issue:** cameleer3-server #112
|
||||
|
||||
## Why
|
||||
|
||||
The current `Sidebar` component is monolithic. It hardcodes three navigation sections (Applications, Agents, Routes), a starred items section, bottom links (Admin, API Docs), and all tree-building logic (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`). The consuming application can only pass `SidebarApp[]` data — it cannot control what sections exist, what order they appear in, or add new sections without modifying this package.
|
||||
|
||||
This blocks two features the consuming application needs:
|
||||
1. **Admin accordion** — when the user enters admin context, the sidebar should expand an Admin section and collapse operational sections, all controlled by the application
|
||||
2. **Icon-rail collapse** — the sidebar should collapse to a narrow icon strip, like modern app sidebars (Linear, VS Code, etc.)
|
||||
|
||||
## Goal
|
||||
|
||||
Refactor `Sidebar` into a composable compound component. The DS provides the frame and building blocks. The consuming application controls all content.
|
||||
|
||||
## Current Exports (to be replaced)
|
||||
|
||||
```typescript
|
||||
// Current — monolithic
|
||||
export { Sidebar } from './Sidebar/Sidebar'
|
||||
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
|
||||
```
|
||||
|
||||
## New Exports
|
||||
|
||||
```typescript
|
||||
// New — composable
|
||||
export { Sidebar } from './Sidebar/Sidebar'
|
||||
export { SidebarTree } from './Sidebar/SidebarTree'
|
||||
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||
export { useStarred } from './Sidebar/useStarred'
|
||||
```
|
||||
|
||||
`SidebarApp`, `SidebarRoute`, `SidebarAgent` types are removed — they are application-domain types that move to the consuming app.
|
||||
|
||||
## Compound Component API
|
||||
|
||||
### `<Sidebar>`
|
||||
|
||||
The outer shell. Renders the sidebar frame with an optional search input and collapse toggle.
|
||||
|
||||
```tsx
|
||||
<Sidebar
|
||||
collapsed={false}
|
||||
onCollapseToggle={() => {}}
|
||||
searchValue=""
|
||||
onSearchChange={(query) => {}}
|
||||
className=""
|
||||
>
|
||||
<Sidebar.Header ... />
|
||||
<Sidebar.Section ... />
|
||||
<Sidebar.Section ... />
|
||||
<Sidebar.Footer ... />
|
||||
</Sidebar>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `collapsed` | `boolean` | `false` | Render as ~48px icon rail |
|
||||
| `onCollapseToggle` | `() => void` | - | Collapse/expand toggle clicked |
|
||||
| `onSearchChange` | `(query: string) => void` | - | Search input changed. Omit to hide search. |
|
||||
| `searchValue` | `string` | `''` | Controlled value for the search input |
|
||||
| `children` | `ReactNode` | - | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
|
||||
| `className` | `string` | - | Additional CSS class |
|
||||
|
||||
**Search state ownership:** The DS renders the search input as a dumb controlled input and calls `onSearchChange` on every keystroke. The consuming application owns the search state and passes it to each `SidebarTree` as `filterQuery`. This lets the app control filtering behavior (e.g., clear search when switching sections, filter only certain sections). The DS does not hold any search state internally.
|
||||
|
||||
**Rendering rules:**
|
||||
- Expanded: full width (~260px), all content visible
|
||||
- Collapsed: ~48px wide, only icons visible, tooltips on hover
|
||||
- Width transition: `transition: width 200ms ease`
|
||||
- Collapse toggle button (`<<` / `>>` chevron) in top-right corner
|
||||
- Search input hidden when collapsed
|
||||
- Search input auto-positioned between `Sidebar.Header` and first `Sidebar.Section` (not above Header)
|
||||
|
||||
### `<Sidebar.Header>`
|
||||
|
||||
Logo, title, and version. In collapsed mode, renders only the logo centered.
|
||||
|
||||
```tsx
|
||||
<Sidebar.Header
|
||||
logo={<img src="..." />}
|
||||
title="cameleer"
|
||||
version="v3.2.1"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `logo` | `ReactNode` | - | Logo element |
|
||||
| `title` | `string` | - | App name (hidden when collapsed) |
|
||||
| `version` | `string` | - | Version text (hidden when collapsed) |
|
||||
|
||||
### `<Sidebar.Section>`
|
||||
|
||||
An accordion section with a collapsible header and content area.
|
||||
|
||||
```tsx
|
||||
<Sidebar.Section
|
||||
label="APPLICATIONS"
|
||||
icon={<Box size={14} />}
|
||||
collapsed={false}
|
||||
onToggle={() => {}}
|
||||
active={false}
|
||||
>
|
||||
<SidebarTree nodes={nodes} ... />
|
||||
</Sidebar.Section>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `label` | `string` | - | Section header text (rendered uppercase via CSS) |
|
||||
| `icon` | `ReactNode` | - | Icon for header and collapsed rail |
|
||||
| `collapsed` | `boolean` | `false` | Whether children are hidden |
|
||||
| `onToggle` | `() => void` | - | Header clicked |
|
||||
| `children` | `ReactNode` | - | Content when expanded |
|
||||
| `active` | `boolean` | - | Override active highlight. If omitted, not highlighted. |
|
||||
|
||||
**Expanded rendering:**
|
||||
```
|
||||
[icon] APPLICATIONS
|
||||
(children rendered here)
|
||||
```
|
||||
|
||||
**Collapsed rendering:**
|
||||
```
|
||||
[icon] APPLICATIONS
|
||||
```
|
||||
|
||||
**In sidebar icon-rail mode:**
|
||||
```
|
||||
[icon] <- centered, tooltip shows label on hover
|
||||
```
|
||||
|
||||
Header has: icon and label (no chevron — the entire row is clickable). Active section gets the amber left-border accent (existing pattern). Clicking anywhere on the header row calls `onToggle`.
|
||||
|
||||
**Implementation detail:** `Sidebar.Section` and `Sidebar.Header` need to know the parent's `collapsed` state to switch between expanded and icon-rail rendering. The `<Sidebar>` component provides `collapsed` and `onCollapseToggle` via React context (`SidebarContext`). Sub-components read from context — no prop drilling needed.
|
||||
|
||||
**Icon-rail click behavior:** In collapsed mode, clicking a section icon fires both `onCollapseToggle` and `onToggle` simultaneously on the same click. The sidebar expands and the section opens in one motion. No navigation occurs — the user is expanding the sidebar to see what's inside, not committing to a destination. They click a tree item after the section is visible to navigate.
|
||||
|
||||
### `<Sidebar.Footer>`
|
||||
|
||||
Pinned to the bottom of the sidebar. Container for `Sidebar.FooterLink` items.
|
||||
|
||||
```tsx
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => {}} />
|
||||
</Sidebar.Footer>
|
||||
```
|
||||
|
||||
In collapsed mode, footer links render as centered icons with tooltips.
|
||||
|
||||
### `<Sidebar.FooterLink>`
|
||||
|
||||
A single bottom link.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `icon` | `ReactNode` | - | Link icon |
|
||||
| `label` | `string` | - | Link text (hidden when collapsed, shown as tooltip) |
|
||||
| `onClick` | `() => void` | - | Click handler |
|
||||
| `active` | `boolean` | `false` | Active state highlight |
|
||||
|
||||
### `<SidebarTree>` (no changes, newly exported)
|
||||
|
||||
Already exists at `Sidebar/SidebarTree.tsx`. No modifications needed — it already accepts all data via props. Just export it from the package.
|
||||
|
||||
**Current props (unchanged):**
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `nodes` | `SidebarTreeNode[]` | Tree data |
|
||||
| `selectedPath` | `string` | Currently active path for highlighting |
|
||||
| `filterQuery` | `string` | Search filter text |
|
||||
| `onNavigate` | `(path: string) => void` | Navigation callback |
|
||||
| `persistKey` | `string` | localStorage key for expand state |
|
||||
| `autoRevealPath` | `string \| null` | Path to auto-expand to |
|
||||
| `isStarred` | `(id: string) => boolean` | Star state checker |
|
||||
| `onToggleStar` | `(id: string) => void` | Star toggle callback |
|
||||
|
||||
### `useStarred` hook (no changes, newly exported)
|
||||
|
||||
Already exists at `Sidebar/useStarred.ts`. Export as-is.
|
||||
|
||||
**Returns:** `{ starredIds, isStarred, toggleStar }`
|
||||
|
||||
## What Gets Removed
|
||||
|
||||
All of this application-specific logic is deleted from the DS:
|
||||
|
||||
1. **`buildAppTreeNodes()`** (~30 lines) — transforms `SidebarApp[]` into `SidebarTreeNode[]`
|
||||
2. **`buildRouteTreeNodes()`** (~20 lines) — transforms apps into route tree nodes
|
||||
3. **`buildAgentTreeNodes()`** (~25 lines) — transforms apps into agent tree nodes with live-count badges
|
||||
4. **`collectStarredItems()`** (~20 lines) — gathers starred items across types
|
||||
5. **`StarredGroup`** sub-component (~30 lines) — renders grouped starred items
|
||||
6. **Hardcoded sections** (~100 lines) — Applications, Agents, Routes section rendering with localStorage persistence
|
||||
7. **Hardcoded bottom links** (~30 lines) — Admin and API Docs links
|
||||
8. **Auto-reveal effect** (~20 lines) — `sidebarRevealPath` effect
|
||||
9. **`SidebarApp`, `SidebarRoute`, `SidebarAgent` types** — domain types, not DS types
|
||||
10. **`formatCount()` helper** — number formatting, moves to consuming app
|
||||
|
||||
Total: ~300 lines of application logic removed, replaced by ~150 lines of compound component shell.
|
||||
|
||||
## CSS Changes
|
||||
|
||||
### New styles needed
|
||||
|
||||
- `.sidebarCollapsed` — narrow width (48px), centered icons
|
||||
- `.collapseToggle` — `<<` / `>>` button positioning
|
||||
- `.sectionIcon` — icon rendering in section headers
|
||||
- `.tooltip` — hover tooltips for collapsed mode
|
||||
- Width transition: `transition: width 200ms ease` on `.sidebar`
|
||||
|
||||
### Styles that stay
|
||||
|
||||
- `.sidebar` (modified: width becomes conditional)
|
||||
- `.searchWrap`, `.searchInput` (unchanged)
|
||||
- `.navArea` (unchanged)
|
||||
- All tree styles in `SidebarTree` (unchanged)
|
||||
|
||||
### Styles removed
|
||||
|
||||
- `.bottom`, `.bottomItem`, `.bottomItemActive` — replaced by `Sidebar.Footer` / `Sidebar.FooterLink` styles
|
||||
- `.starredSection`, `.starredGroup`, `.starredItem`, `.starredRemove` — starred rendering moves to app
|
||||
- `.section` — replaced by `Sidebar.Section` styles
|
||||
|
||||
## File Structure After Refactor
|
||||
|
||||
```
|
||||
Sidebar/
|
||||
├── Sidebar.tsx # Compound component: Sidebar, Sidebar.Header,
|
||||
│ # Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink
|
||||
├── Sidebar.module.css # Updated styles (shell + section + footer + collapsed)
|
||||
├── SidebarTree.tsx # Unchanged
|
||||
├── SidebarTree.module.css # Unchanged (if separate, otherwise stays in Sidebar.module.css)
|
||||
├── useStarred.ts # Unchanged
|
||||
├── useStarred.test.ts # Unchanged
|
||||
└── Sidebar.test.tsx # Updated for new compound API
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Update `Sidebar.test.tsx` to test the compound component API:
|
||||
|
||||
- Renders Header with logo, title, version
|
||||
- Renders Sections with labels and icons
|
||||
- Section toggle calls `onToggle`
|
||||
- Collapsed sections hide children
|
||||
- Sidebar collapsed mode renders icon rail
|
||||
- Collapse toggle calls `onCollapseToggle`
|
||||
- Footer links render with icons and labels
|
||||
- Collapsed mode hides labels, shows tooltips
|
||||
- Search input calls `onSearchChange`
|
||||
- Search hidden when sidebar collapsed
|
||||
- Section icon click in collapsed mode calls both `onCollapseToggle` and `onToggle`
|
||||
|
||||
`SidebarTree` tests are unaffected.
|
||||
|
||||
## Usage Example (for reference)
|
||||
|
||||
This is how the consuming application (cameleer3-server) will use the new API. This code does NOT live in the design system — it's shown for context only.
|
||||
|
||||
```tsx
|
||||
// In LayoutShell.tsx (consuming app)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const [appsCollapsed, setAppsCollapsed] = useState(false);
|
||||
const [agentsCollapsed, setAgentsCollapsed] = useState(false);
|
||||
const [routesCollapsed, setRoutesCollapsed] = useState(true);
|
||||
const [adminCollapsed, setAdminCollapsed] = useState(true);
|
||||
|
||||
// Accordion: entering admin expands admin, collapses others
|
||||
useEffect(() => {
|
||||
if (isAdminPage) {
|
||||
setAdminCollapsed(false);
|
||||
setAppsCollapsed(true);
|
||||
setAgentsCollapsed(true);
|
||||
setRoutesCollapsed(true);
|
||||
} else {
|
||||
setAdminCollapsed(true);
|
||||
// restore previous operational states
|
||||
}
|
||||
}, [isAdminPage]);
|
||||
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
|
||||
searchValue={filterQuery}
|
||||
onSearchChange={setFilterQuery}
|
||||
>
|
||||
<Sidebar.Header logo={<CameleerLogo />} title="cameleer" version="v3.2.1" />
|
||||
|
||||
{isAdminPage && (
|
||||
<Sidebar.Section label="ADMIN" icon={<Settings size={14} />}
|
||||
collapsed={adminCollapsed} onToggle={() => setAdminCollapsed(v => !v)}>
|
||||
<SidebarTree nodes={adminNodes} ... filterQuery={filterQuery} />
|
||||
</Sidebar.Section>
|
||||
)}
|
||||
|
||||
<Sidebar.Section label="APPLICATIONS" icon={<Box size={14} />}
|
||||
collapsed={appsCollapsed} onToggle={() => { setAppsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||
<SidebarTree nodes={appNodes} ... filterQuery={filterQuery} />
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section label="AGENTS" icon={<Cpu size={14} />}
|
||||
collapsed={agentsCollapsed} onToggle={() => { setAgentsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||
<SidebarTree nodes={agentNodes} ... filterQuery={filterQuery} />
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section label="ROUTES" icon={<GitBranch size={14} />}
|
||||
collapsed={routesCollapsed} onToggle={() => { setRoutesCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||
<SidebarTree nodes={routeNodes} ... filterQuery={filterQuery} />
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
```
|
||||
|
||||
## Mock App Migration — LayoutShell
|
||||
|
||||
The 11 page files currently duplicating `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>` will be consolidated into a single `LayoutShell` component.
|
||||
|
||||
### `src/layout/LayoutShell.tsx`
|
||||
|
||||
Composes the sidebar once using the new compound API. All page-specific content is rendered via `<Outlet />`.
|
||||
|
||||
```tsx
|
||||
// src/layout/LayoutShell.tsx
|
||||
export function LayoutShell() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [appsCollapsed, setAppsCollapsed] = useState(false)
|
||||
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
|
||||
const [routesCollapsed, setRoutesCollapsed] = useState(false)
|
||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||
const location = useLocation()
|
||||
// ... build tree nodes from SIDEBAR_APPS, starred section, etc.
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
|
||||
searchValue={filterQuery}
|
||||
onSearchChange={setFilterQuery}
|
||||
>
|
||||
<Sidebar.Header logo={...} title="cameleer" version="v3.2.1" />
|
||||
<Sidebar.Section label="Applications" icon={...}
|
||||
collapsed={appsCollapsed} onToggle={() => setAppsCollapsed(v => !v)}>
|
||||
<SidebarTree nodes={appNodes} filterQuery={filterQuery} ... />
|
||||
</Sidebar.Section>
|
||||
<Sidebar.Section label="Agents" icon={...}
|
||||
collapsed={agentsCollapsed} onToggle={() => setAgentsCollapsed(v => !v)}>
|
||||
<SidebarTree nodes={agentNodes} filterQuery={filterQuery} ... />
|
||||
</Sidebar.Section>
|
||||
<Sidebar.Section label="Routes" icon={...}
|
||||
collapsed={routesCollapsed} onToggle={() => setRoutesCollapsed(v => !v)}>
|
||||
<SidebarTree nodes={routeNodes} filterQuery={filterQuery} ... />
|
||||
</Sidebar.Section>
|
||||
{/* Starred section built from useStarred + SIDEBAR_APPS */}
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={...} label="Admin" ... />
|
||||
<Sidebar.FooterLink icon={...} label="API Docs" ... />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Route structure change
|
||||
|
||||
`App.tsx` switches from per-page `<Route element={<Page />}>` to a layout route:
|
||||
|
||||
```tsx
|
||||
<Route element={<LayoutShell />}>
|
||||
<Route path="/apps" element={<Dashboard />} />
|
||||
<Route path="/apps/:id" element={<Dashboard />} />
|
||||
...all existing routes...
|
||||
</Route>
|
||||
```
|
||||
|
||||
All tree-building helpers (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`), starred section logic (`collectStarredItems`, `StarredGroup`), `formatCount`, and `sidebarRevealPath` handling move from `Sidebar.tsx` into `LayoutShell.tsx`. Each page file loses its `<AppShell sidebar={...}>` wrapper and becomes just the page content.
|
||||
|
||||
The Inventory page's `LayoutSection` keeps its own inline `<Sidebar>` demo with `SAMPLE_APPS` data — it's a showcase, not a navigation shell.
|
||||
|
||||
## Breaking Change
|
||||
|
||||
This is a **breaking change** to the `Sidebar` API. The old `<Sidebar apps={[...]} onNavigate={...} />` signature is removed entirely. The consuming application must migrate to the compound component API in the same release cycle.
|
||||
|
||||
Coordinate: bump DS version, update server UI, deploy together.
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"main": "./dist/index.es.js",
|
||||
"module": "./dist/index.es.js",
|
||||
@@ -10,10 +10,16 @@
|
||||
"types": "./dist/index.es.d.ts",
|
||||
"import": "./dist/index.es.js"
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
"./style.css": "./dist/style.css",
|
||||
"./assets/*": "./assets/*"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"sideEffects": ["*.css"],
|
||||
"files": [
|
||||
"dist",
|
||||
"assets"
|
||||
],
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||
},
|
||||
@@ -27,9 +33,11 @@
|
||||
"build:lib": "vite build --config vite.lib.config.ts",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
@@ -40,12 +48,14 @@
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"happy-dom": "^20.8.4",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
35
src/App.tsx
@@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData'
|
||||
import { exchanges } from './mocks/exchanges'
|
||||
import { routes } from './mocks/routes'
|
||||
import { agents } from './mocks/agents'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
|
||||
import { buildRouteToAppMap } from './mocks/sidebar'
|
||||
import { LayoutShell } from './layout/LayoutShell'
|
||||
|
||||
const routeToApp = buildRouteToAppMap()
|
||||
|
||||
@@ -78,21 +79,23 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="/apps" element={<Dashboard />} />
|
||||
<Route path="/apps/:id" element={<Dashboard />} />
|
||||
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/routes/:appId" element={<RoutesPage />} />
|
||||
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
|
||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
|
||||
<Route path="/agents/*" element={<AgentHealth />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||||
<Route path="/admin/audit" element={<AuditLog />} />
|
||||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||
<Route path="/api-docs" element={<ApiDocs />} />
|
||||
<Route element={<LayoutShell />}>
|
||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="/apps" element={<Dashboard />} />
|
||||
<Route path="/apps/:id" element={<Dashboard />} />
|
||||
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/routes/:appId" element={<RoutesPage />} />
|
||||
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
|
||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
|
||||
<Route path="/agents/*" element={<AgentHealth />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||||
<Route path="/admin/audit" element={<AuditLog />} />
|
||||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||
<Route path="/api-docs" element={<ApiDocs />} />
|
||||
</Route>
|
||||
<Route path="/inventory" element={<Inventory />} />
|
||||
</Routes>
|
||||
<CommandPalette
|
||||
|
||||
BIN
src/assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
|
||||
|
||||
it('renders danger variant icon', () => {
|
||||
render(<AlertDialog {...defaultProps} variant="danger" />)
|
||||
// Icon area should be present (aria-hidden)
|
||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
||||
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders warning variant icon', () => {
|
||||
render(<AlertDialog {...defaultProps} variant="warning" />)
|
||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
||||
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders info variant icon', () => {
|
||||
render(<AlertDialog {...defaultProps} variant="info" />)
|
||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
||||
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import { Modal } from '../Modal/Modal'
|
||||
import { Button } from '../../primitives/Button/Button'
|
||||
import styles from './AlertDialog.module.css'
|
||||
@@ -16,10 +17,10 @@ interface AlertDialogProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
|
||||
danger: '✕',
|
||||
warning: '⚠',
|
||||
info: 'ℹ',
|
||||
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
|
||||
danger: <XCircle size={20} />,
|
||||
warning: <AlertTriangle size={20} />,
|
||||
info: <Info size={20} />,
|
||||
}
|
||||
|
||||
export function AlertDialog({
|
||||
|
||||
@@ -83,6 +83,20 @@ export function BarChart({
|
||||
setTooltip({ x: mx, y: my, label: catLabel, values })
|
||||
}
|
||||
|
||||
function showBarTooltip(e: React.MouseEvent<SVGRectElement>, cat: string) {
|
||||
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
||||
handleMouseEnter(
|
||||
cat,
|
||||
e.clientX - rect.left,
|
||||
e.clientY - rect.top,
|
||||
series.map((ss, ssi) => ({
|
||||
series: ss.label,
|
||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
||||
@@ -138,19 +152,7 @@ export function BarChart({
|
||||
height={barH}
|
||||
fill={color}
|
||||
className={styles.bar}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
||||
handleMouseEnter(
|
||||
cat,
|
||||
e.clientX - rect.left,
|
||||
e.clientY - rect.top,
|
||||
series.map((ss, ssi) => ({
|
||||
series: ss.label,
|
||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
||||
})),
|
||||
)
|
||||
}}
|
||||
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -184,19 +186,7 @@ export function BarChart({
|
||||
height={barH}
|
||||
fill={color}
|
||||
className={styles.bar}
|
||||
onMouseEnter={(e) => {
|
||||
const svgEl = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
||||
handleMouseEnter(
|
||||
cat,
|
||||
e.clientX - svgEl.left,
|
||||
e.clientY - svgEl.top,
|
||||
series.map((ss, ssi) => ({
|
||||
series: ss.label,
|
||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
||||
})),
|
||||
)
|
||||
}}
|
||||
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -277,6 +277,23 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Match context snippet */
|
||||
.matchContext {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-mono);
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.matchContext em {
|
||||
font-style: normal;
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Match highlight */
|
||||
.mark {
|
||||
background: none;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import styles from './CommandPalette.module.css'
|
||||
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
||||
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
||||
@@ -12,24 +13,36 @@ interface CommandPaletteProps {
|
||||
onSelect: (result: SearchResult) => void
|
||||
data: SearchResult[]
|
||||
onOpen?: () => void
|
||||
onQueryChange?: (query: string) => void
|
||||
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
|
||||
* Useful for applying the query as a full-text search filter. */
|
||||
onSubmit?: (query: string) => void
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||
all: 'All',
|
||||
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
|
||||
application: 'Applications',
|
||||
exchange: 'Exchanges',
|
||||
attribute: 'Attributes',
|
||||
route: 'Routes',
|
||||
agent: 'Agents',
|
||||
}
|
||||
|
||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||
'all',
|
||||
/** Preferred display order for known categories */
|
||||
const KNOWN_CATEGORY_ORDER: string[] = [
|
||||
'application',
|
||||
'exchange',
|
||||
'attribute',
|
||||
'route',
|
||||
'agent',
|
||||
]
|
||||
|
||||
function categoryLabel(cat: string): string {
|
||||
if (cat === 'all') return 'All'
|
||||
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
|
||||
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
|
||||
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
||||
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
||||
|
||||
@@ -60,12 +73,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
|
||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const userNavigated = useRef(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -88,25 +102,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
setQuery('')
|
||||
setFocusedIdx(0)
|
||||
setExpandedId(null)
|
||||
userNavigated.current = false
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Filter results
|
||||
const filtered = useMemo(() => {
|
||||
// Stage 1: apply text query + scope filters (used for counts)
|
||||
const queryFiltered = useMemo(() => {
|
||||
let results = data
|
||||
|
||||
if (activeCategory !== 'all') {
|
||||
results = results.filter((r) => r.category === activeCategory)
|
||||
}
|
||||
|
||||
if (query.trim()) {
|
||||
const q = query.toLowerCase()
|
||||
results = results.filter(
|
||||
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
||||
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
|
||||
// Apply scope filters
|
||||
for (const sf of scopeFilters) {
|
||||
results = results.filter((r) =>
|
||||
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
||||
@@ -114,11 +124,17 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
}
|
||||
|
||||
return results
|
||||
}, [data, query, activeCategory, scopeFilters])
|
||||
}, [data, query, scopeFilters])
|
||||
|
||||
// Stage 2: apply category filter (used for display)
|
||||
const filtered = useMemo(() => {
|
||||
if (activeCategory === 'all') return queryFiltered
|
||||
return queryFiltered.filter((r) => r.category === activeCategory)
|
||||
}, [queryFiltered, activeCategory])
|
||||
|
||||
// Group results by category
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<SearchCategory, SearchResult[]>()
|
||||
const map = new Map<string, SearchResult[]>()
|
||||
for (const r of filtered) {
|
||||
if (!map.has(r.category)) map.set(r.category, [])
|
||||
map.get(r.category)!.push(r)
|
||||
@@ -129,13 +145,26 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
// Flatten for keyboard nav
|
||||
const flatResults = useMemo(() => filtered, [filtered])
|
||||
|
||||
// Counts per category
|
||||
// Counts per category (from query-filtered, before category filter)
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: data.length }
|
||||
for (const r of data) {
|
||||
const counts: Record<string, number> = { all: queryFiltered.length }
|
||||
for (const r of queryFiltered) {
|
||||
counts[r.category] = (counts[r.category] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
}, [queryFiltered])
|
||||
|
||||
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
|
||||
const visibleCategories = useMemo(() => {
|
||||
const dataCategories = new Set(data.map((r) => r.category))
|
||||
const tabs: string[] = ['all']
|
||||
for (const cat of KNOWN_CATEGORY_ORDER) {
|
||||
if (dataCategories.has(cat)) tabs.push(cat)
|
||||
}
|
||||
for (const cat of dataCategories) {
|
||||
if (!tabs.includes(cat)) tabs.push(cat)
|
||||
}
|
||||
return tabs
|
||||
}, [data])
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
@@ -145,15 +174,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
userNavigated.current = true
|
||||
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
userNavigated.current = true
|
||||
setFocusedIdx((i) => Math.max(i - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (flatResults[focusedIdx]) {
|
||||
if (!userNavigated.current && onSubmit && query.trim()) {
|
||||
onSubmit(query.trim())
|
||||
onClose()
|
||||
} else if (flatResults[focusedIdx]) {
|
||||
onSelect(flatResults[focusedIdx])
|
||||
onClose()
|
||||
}
|
||||
@@ -171,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function toggleExpanded(e: React.MouseEvent, id: string) {
|
||||
e.stopPropagation()
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.overlay} onClick={onClose} data-testid="command-palette-overlay">
|
||||
<div
|
||||
className={styles.overlay}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClose() }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close command palette"
|
||||
data-testid="command-palette-overlay"
|
||||
>
|
||||
<div
|
||||
className={styles.panel}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -185,7 +232,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
>
|
||||
{/* Search input area */}
|
||||
<div className={styles.searchArea}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">⌕</span>
|
||||
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
|
||||
{scopeFilters.map((sf, i) => (
|
||||
<span key={i} className={styles.scopeTag}>
|
||||
<span className={styles.scopeField}>{sf.field}:</span>
|
||||
@@ -195,7 +242,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
onClick={() => removeScopeFilter(i)}
|
||||
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
||||
>
|
||||
×
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
@@ -208,6 +255,8 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setFocusedIdx(0)
|
||||
userNavigated.current = false
|
||||
onQueryChange?.(e.target.value)
|
||||
}}
|
||||
aria-label="Search"
|
||||
/>
|
||||
@@ -216,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className={styles.tabs} role="tablist">
|
||||
{ALL_CATEGORIES.map((cat) => (
|
||||
{visibleCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
role="tab"
|
||||
@@ -232,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
setFocusedIdx(0)
|
||||
}}
|
||||
>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
{categoryLabel(cat)}
|
||||
{categoryCounts[cat] != null && (
|
||||
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
||||
)}
|
||||
@@ -253,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
Array.from(grouped.entries()).map(([category, items]) => (
|
||||
<div key={category} className={styles.group}>
|
||||
<div className={styles.groupHeader}>
|
||||
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
|
||||
<SectionHeader>{categoryLabel(category)}</SectionHeader>
|
||||
</div>
|
||||
{items.map((result) => {
|
||||
const flatIdx = flatResults.indexOf(result)
|
||||
@@ -276,7 +325,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
onSelect(result)
|
||||
onClose()
|
||||
}}
|
||||
onMouseEnter={() => setFocusedIdx(flatIdx)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelect(result)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
||||
>
|
||||
<div className={styles.itemMain}>
|
||||
{result.icon && (
|
||||
@@ -301,18 +356,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
<div className={styles.itemMeta}>
|
||||
{highlightText(result.meta, query)}
|
||||
</div>
|
||||
{result.matchContext && (
|
||||
<div
|
||||
className={styles.matchContext}
|
||||
dangerouslySetInnerHTML={{ __html: result.matchContext }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{result.expandedContent && (
|
||||
<button
|
||||
className={styles.expandBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpandedId((prev) => (prev === result.id ? null : result.id))
|
||||
}}
|
||||
onClick={(e) => toggleExpanded(e, result.id)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label="Toggle detail"
|
||||
>
|
||||
{isExpanded ? '▲' : '▼'}
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -341,7 +399,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
</div>
|
||||
<div className={styles.shortcut}>
|
||||
<KeyboardHint keys="Enter" />
|
||||
<span>Open</span>
|
||||
<span>Search</span>
|
||||
</div>
|
||||
<div className={styles.shortcut}>
|
||||
<KeyboardHint keys="Esc" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
||||
/** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
|
||||
export type SearchCategory = string
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
@@ -13,6 +14,10 @@ export interface SearchResult {
|
||||
path?: string
|
||||
expandedContent?: string
|
||||
matchRanges?: [number, number][]
|
||||
/** Skip client-side query filtering (result already matched server-side) */
|
||||
serverFiltered?: boolean
|
||||
/** Server-side match snippet with <em> tags around matched text */
|
||||
matchContext?: string
|
||||
}
|
||||
|
||||
export interface ScopeFilter {
|
||||
|
||||
@@ -12,6 +12,23 @@
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -35,6 +52,9 @@
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: color 0.12s;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.th.sortable {
|
||||
|
||||
@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
|
||||
rowAccent,
|
||||
expandedContent,
|
||||
flush = false,
|
||||
fillHeight = false,
|
||||
onSortChange,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||
@@ -81,7 +82,7 @@ export function DataTable<T extends { id: string }>({
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
|
||||
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
|
||||
<div className={styles.scroll}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface DataTableProps<T extends { id: string }> {
|
||||
expandedContent?: (row: T) => ReactNode | null
|
||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||
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. */
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
z-index: 99;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.25s ease, opacity 0.2s ease;
|
||||
@@ -7,13 +24,15 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.panel.open {
|
||||
width: 400px;
|
||||
opacity: 1;
|
||||
border-left-color: var(--border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideInRight 0.25s ease-out both;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './DetailPanel.module.css'
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<button
|
||||
className={styles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
const content = (
|
||||
<>
|
||||
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
|
||||
<aside
|
||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<button
|
||||
className={styles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.body}>
|
||||
{children ?? activeContent}
|
||||
</div>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
<div className={styles.actions}>
|
||||
{actions}
|
||||
<div className={styles.body}>
|
||||
{children ?? activeContent}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{actions && (
|
||||
<div className={styles.actions}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
|
||||
// Portal to AppShell level if target exists, otherwise render in place
|
||||
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||
return portalTarget ? createPortal(content, portalTarget) : content
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
|
||||
import styles from './EventFeed.module.css'
|
||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
||||
error: '\u2715', // ✕
|
||||
warning: '\u26A0', // ⚠
|
||||
success: '\u25B6', // ▶
|
||||
running: '\u2699', // ⚙
|
||||
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
|
||||
error: <XIcon size={14} />,
|
||||
warning: <AlertTriangle size={14} />,
|
||||
success: <Play size={14} />,
|
||||
running: <Loader size={14} />,
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
||||
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
||||
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
||||
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
||||
|
||||
// Auto-scroll to bottom
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// Auto-scroll to top (newest entries are at top in desc sort)
|
||||
const scrollToTop = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
el.scrollTop = 0
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaused) {
|
||||
scrollToBottom()
|
||||
scrollToTop()
|
||||
}
|
||||
}, [events, isPaused, scrollToBottom])
|
||||
}, [events, isPaused, scrollToTop])
|
||||
|
||||
function handleScroll() {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
|
||||
setIsPaused(!atBottom)
|
||||
const atTop = el.scrollTop < 8
|
||||
setIsPaused(!atTop)
|
||||
}
|
||||
|
||||
function toggleFilter(severity: SeverityFilter) {
|
||||
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
||||
onClick={() => setSearch('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
||||
className={styles.resumeBtn}
|
||||
onClick={() => {
|
||||
setIsPaused(false)
|
||||
scrollToBottom()
|
||||
scrollToTop()
|
||||
}}
|
||||
>
|
||||
↓ Resume auto-scroll
|
||||
↑ Scroll to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import styles from './FilterBar.module.css'
|
||||
import { Input } from '../../primitives/Input/Input'
|
||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||
@@ -77,12 +78,7 @@ export function FilterBar({
|
||||
if (onSearchChange) onSearchChange('')
|
||||
else setInternalSearch('')
|
||||
} : undefined}
|
||||
icon={
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
}
|
||||
icon={<Search size={13} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import styles from './KpiStrip.module.css'
|
||||
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
export interface KpiItem {
|
||||
label: string
|
||||
value: string | number
|
||||
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||
trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||
subtitle?: string
|
||||
sparkline?: number[]
|
||||
borderColor?: string
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
.container {
|
||||
overflow-y: auto;
|
||||
background: var(--bg-inset);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -58,6 +56,11 @@
|
||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||
}
|
||||
|
||||
.levelTrace {
|
||||
color: var(--text-faint);
|
||||
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@@ -7,6 +7,7 @@ const entries: LogEntry[] = [
|
||||
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
||||
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
||||
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
||||
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
|
||||
]
|
||||
|
||||
describe('LogViewer', () => {
|
||||
@@ -16,14 +17,16 @@ describe('LogViewer', () => {
|
||||
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
|
||||
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
|
||||
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
|
||||
render(<LogViewer entries={entries} />)
|
||||
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||
expect(screen.getByText('TRACE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom maxHeight (number)', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string
|
||||
level: 'info' | 'warn' | 'error' | 'debug'
|
||||
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
||||
warn: styles.levelWarn,
|
||||
error: styles.levelError,
|
||||
debug: styles.levelDebug,
|
||||
trace: styles.levelTrace,
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
@@ -35,18 +36,18 @@ function formatTime(iso: string): string {
|
||||
|
||||
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
const isAtTopRef = useRef(true)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
||||
isAtTopRef.current = el.scrollTop < 20
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && isAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
if (el && isAtTopRef.current) {
|
||||
el.scrollTop = 0
|
||||
}
|
||||
}, [entries])
|
||||
|
||||
|
||||
@@ -96,6 +96,58 @@
|
||||
padding: 2px 0 2px 4px;
|
||||
}
|
||||
|
||||
/* Action trigger — hidden by default, shown on hover/selected */
|
||||
.actionsTrigger {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row:hover .actionsTrigger,
|
||||
.actionsVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.actionsBtn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.1s;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.actionsBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 7px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0 4px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badgeInfo { background: var(--running); }
|
||||
.badgeSuccess { background: var(--success); }
|
||||
.badgeWarning { background: var(--amber); }
|
||||
.badgeError { background: var(--error); }
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -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 { Dropdown } from '../Dropdown/Dropdown'
|
||||
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
||||
|
||||
export interface ProcessorStep {
|
||||
name: string
|
||||
@@ -6,6 +10,15 @@ export interface ProcessorStep {
|
||||
durationMs: number
|
||||
status: 'ok' | 'slow' | 'fail'
|
||||
startMs: number
|
||||
badges?: NodeBadge[]
|
||||
}
|
||||
|
||||
export interface ProcessorAction {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface ProcessorTimelineProps {
|
||||
@@ -13,6 +26,8 @@ interface ProcessorTimelineProps {
|
||||
totalMs: number
|
||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||
selectedIndex?: number
|
||||
actions?: ProcessorAction[]
|
||||
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -26,6 +41,8 @@ export function ProcessorTimeline({
|
||||
totalMs,
|
||||
onProcessorClick,
|
||||
selectedIndex,
|
||||
actions,
|
||||
getActions,
|
||||
className,
|
||||
}: ProcessorTimelineProps) {
|
||||
const safeTotal = totalMs || 1
|
||||
@@ -70,6 +87,16 @@ export function ProcessorTimeline({
|
||||
>
|
||||
<div className={styles.name} title={proc.name}>
|
||||
{proc.name}
|
||||
{proc.badges?.map((badge, bi) => (
|
||||
<span
|
||||
key={bi}
|
||||
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.barBg}>
|
||||
<div
|
||||
@@ -82,6 +109,30 @@ export function ProcessorTimeline({
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
||||
{(() => {
|
||||
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
|
||||
if (resolvedActions.length === 0) return null
|
||||
return (
|
||||
<div
|
||||
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button
|
||||
className={styles.actionsBtn}
|
||||
aria-label={`Actions for ${proc.name}`}
|
||||
type="button"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</button>
|
||||
}
|
||||
items={resolvedActions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -188,17 +188,100 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Bottleneck badge */
|
||||
.bottleneckBadge {
|
||||
/* Action trigger — hidden by default, shown on hover/selected */
|
||||
.actionsTrigger {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.node:hover .actionsTrigger,
|
||||
.actionsVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.actionsBtn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.1s;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.actionsBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badgeRow {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.badgeInfo { background: var(--running); }
|
||||
.badgeSuccess { background: var(--success); }
|
||||
.badgeWarning { background: var(--amber); }
|
||||
.badgeError { background: var(--error); }
|
||||
|
||||
/* Node wrapper (replaces inline style) */
|
||||
.nodeWrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Multi-flow sections */
|
||||
.flowSection {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flowSectionSeparated {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.flowLabel {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flowLabelDefault { color: var(--text-muted); }
|
||||
.flowLabelError { color: var(--error); }
|
||||
.flowLabelWarning { color: var(--warning); }
|
||||
.flowLabelInfo { color: var(--running); }
|
||||
|
||||
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 { Dropdown } from '../Dropdown/Dropdown'
|
||||
|
||||
export interface NodeBadge {
|
||||
label: string
|
||||
variant?: 'info' | 'success' | 'warning' | 'error'
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export interface RouteNode {
|
||||
name: string
|
||||
@@ -6,12 +15,30 @@ export interface RouteNode {
|
||||
durationMs: number
|
||||
status: 'ok' | 'slow' | 'fail'
|
||||
isBottleneck?: boolean
|
||||
badges?: NodeBadge[]
|
||||
}
|
||||
|
||||
export interface NodeAction {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
export interface FlowSegment {
|
||||
label: string
|
||||
nodes: RouteNode[]
|
||||
variant?: 'default' | 'error' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
interface RouteFlowProps {
|
||||
nodes: RouteNode[]
|
||||
nodes?: RouteNode[]
|
||||
flows?: FlowSegment[]
|
||||
onNodeClick?: (node: RouteNode, index: number) => void
|
||||
selectedIndex?: number
|
||||
actions?: NodeAction[]
|
||||
getActions?: (node: RouteNode, index: number) => NodeAction[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -29,12 +56,12 @@ function durationClass(ms: number, status: string): string {
|
||||
return styles.durBreach
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
'from': '\u25B6',
|
||||
'process': '\u2699',
|
||||
'to': '\u25A2',
|
||||
'choice': '\u25C6',
|
||||
'error-handler': '\u26A0',
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
'from': <Play size={14} />,
|
||||
'process': <Cog size={14} />,
|
||||
'to': <Square size={14} />,
|
||||
'choice': <Diamond size={14} />,
|
||||
'error-handler': <AlertTriangle size={14} />,
|
||||
}
|
||||
|
||||
const ICON_CLASSES: Record<string, string> = {
|
||||
@@ -52,12 +79,141 @@ function nodeStatusClass(node: RouteNode): string {
|
||||
return styles.nodeHealthy
|
||||
}
|
||||
|
||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
|
||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
||||
function renderActionTrigger(
|
||||
node: RouteNode,
|
||||
index: number,
|
||||
isSelected: boolean,
|
||||
actions?: NodeAction[],
|
||||
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||
) {
|
||||
const resolvedActions = getActions ? getActions(node, index) : (actions ?? [])
|
||||
if (resolvedActions.length === 0) return null
|
||||
return (
|
||||
<div
|
||||
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button
|
||||
className={styles.actionsBtn}
|
||||
aria-label={`Actions for ${node.name}`}
|
||||
type="button"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</button>
|
||||
}
|
||||
items={resolvedActions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FLOW_LABEL_CLASSES: Record<string, string> = {
|
||||
'default': styles.flowLabelDefault,
|
||||
'error': styles.flowLabelError,
|
||||
'warning': styles.flowLabelWarning,
|
||||
'info': styles.flowLabelInfo,
|
||||
}
|
||||
|
||||
function renderNodeChain(
|
||||
nodes: RouteNode[],
|
||||
globalIndexOffset: number,
|
||||
onNodeClick?: RouteFlowProps['onNodeClick'],
|
||||
selectedIndex?: number,
|
||||
actions?: NodeAction[],
|
||||
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||
) {
|
||||
const isClickable = !!onNodeClick
|
||||
|
||||
return nodes.map((node, i) => {
|
||||
const globalIndex = globalIndexOffset + i
|
||||
const isSelected = selectedIndex === globalIndex
|
||||
|
||||
return (
|
||||
<div key={i} className={styles.nodeWrapper}>
|
||||
{i > 0 && (
|
||||
<div className={styles.connector}>
|
||||
<div className={styles.connectorLine} />
|
||||
<div className={styles.connectorArrow} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
|
||||
onClick={() => onNodeClick?.(node, globalIndex)}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
onNodeClick?.(node, globalIndex)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(node.isBottleneck || node.badges?.length) ? (
|
||||
<span className={styles.badgeRow}>
|
||||
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||
{node.badges?.map((badge, bi) => (
|
||||
<span
|
||||
key={bi}
|
||||
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.type}>{node.type}</div>
|
||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||
{formatDuration(node.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
||||
// Multi-flow mode
|
||||
if (flows && flows.length > 0) {
|
||||
let globalOffset = 0
|
||||
return (
|
||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||
{flows.map((flow, fi) => {
|
||||
const sectionOffset = globalOffset
|
||||
globalOffset += flow.nodes.length
|
||||
const variant = flow.variant ?? 'default'
|
||||
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
|
||||
return (
|
||||
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
|
||||
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
|
||||
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy mode (single nodes array with automatic error-handler separation)
|
||||
const allNodes = nodes ?? []
|
||||
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
|
||||
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
|
||||
|
||||
// Map from mainNodes index back to original nodes index
|
||||
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
|
||||
const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
|
||||
if (n.type !== 'error-handler') acc.push(idx)
|
||||
return acc
|
||||
}, [])
|
||||
@@ -70,7 +226,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
||||
const isClickable = !!onNodeClick
|
||||
|
||||
return (
|
||||
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div key={i} className={styles.nodeWrapper}>
|
||||
{i > 0 && (
|
||||
<div className={styles.connector}>
|
||||
<div className={styles.connectorLine} />
|
||||
@@ -89,9 +245,23 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
|
||||
{(node.isBottleneck || node.badges?.length) ? (
|
||||
<span className={styles.badgeRow}>
|
||||
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||
{node.badges?.map((badge, bi) => (
|
||||
<span
|
||||
key={bi}
|
||||
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
||||
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.type}>{node.type}</div>
|
||||
@@ -102,6 +272,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
||||
{formatDuration(node.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -110,22 +281,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
||||
{errorHandlers.length > 0 && (
|
||||
<div className={styles.errorSection}>
|
||||
<div className={styles.errorLabel}>Error Handler</div>
|
||||
{errorHandlers.map((node, i) => (
|
||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||
{TYPE_ICONS['error-handler']}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.type}>{node.type}</div>
|
||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||
{formatDuration(node.durationMs)}
|
||||
{errorHandlers.map((node, i) => {
|
||||
const errOriginalIndex = allNodes.indexOf(node)
|
||||
return (
|
||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||
{TYPE_ICONS['error-handler']}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.type}>{node.type}</div>
|
||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||
{formatDuration(node.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('Toast', () => {
|
||||
|
||||
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
||||
|
||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct icon for success variant', () => {
|
||||
@@ -97,7 +97,7 @@ describe('Toast', () => {
|
||||
|
||||
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
||||
|
||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct icon for warning variant', () => {
|
||||
@@ -105,7 +105,7 @@ describe('Toast', () => {
|
||||
|
||||
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
||||
|
||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct icon for error variant', () => {
|
||||
@@ -113,7 +113,7 @@ describe('Toast', () => {
|
||||
|
||||
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
||||
|
||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses toast when close button is clicked', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||
import styles from './Toast.module.css'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
|
||||
const DEFAULT_DURATION = 5000
|
||||
const EXIT_ANIMATION_MS = 300
|
||||
|
||||
const ICONS: Record<ToastVariant, string> = {
|
||||
info: 'ℹ',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✕',
|
||||
const ICONS: Record<ToastVariant, ReactNode> = {
|
||||
info: <Info size={16} />,
|
||||
success: <CheckCircle size={16} />,
|
||||
warning: <AlertTriangle size={16} />,
|
||||
error: <XCircle size={16} />,
|
||||
}
|
||||
|
||||
// ── Context ────────────────────────────────────────────────────────────────
|
||||
@@ -56,6 +57,10 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
// Clear auto-dismiss timer if running
|
||||
const timer = timersRef.current.get(id)
|
||||
@@ -70,10 +75,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
// Remove after animation completes
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, EXIT_ANIMATION_MS)
|
||||
}, [])
|
||||
setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
|
||||
}, [removeToast])
|
||||
|
||||
const toast = useCallback(
|
||||
(options: ToastOptions): string => {
|
||||
@@ -183,7 +186,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
|
||||
aria-label="Dismiss notification"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,6 +31,52 @@ function flattenVisibleNodes(
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
}
|
||||
|
||||
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||
const prev = visibleNodes[currentIndex - 1]
|
||||
if (prev) focusNode(prev.node.id)
|
||||
}
|
||||
|
||||
function handleArrowRight(
|
||||
current: FlatNode | undefined,
|
||||
currentIndex: number,
|
||||
expandedSet: Set<string>,
|
||||
visibleNodes: FlatNode[],
|
||||
handleToggle: (id: string) => void,
|
||||
focusNode: (id: string) => void,
|
||||
) {
|
||||
if (!current) return
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (!hasChildren) return
|
||||
if (!expandedSet.has(current.node.id)) {
|
||||
handleToggle(current.node.id)
|
||||
} else {
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleArrowLeft(
|
||||
current: FlatNode | undefined,
|
||||
expandedSet: Set<string>,
|
||||
handleToggle: (id: string) => void,
|
||||
focusNode: (id: string) => void,
|
||||
) {
|
||||
if (!current) return
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (hasChildren && expandedSet.has(current.node.id)) {
|
||||
handleToggle(current.node.id)
|
||||
} else if (current.parentId !== null) {
|
||||
focusNode(current.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
interface TreeViewProps {
|
||||
nodes: TreeNode[]
|
||||
onSelect?: (id: string) => void
|
||||
@@ -105,68 +151,13 @@ export function TreeView({
|
||||
const current = visibleNodes[currentIndex]
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
const prev = visibleNodes[currentIndex - 1]
|
||||
if (prev) focusNode(prev.node.id)
|
||||
break
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault()
|
||||
if (!current) break
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (hasChildren) {
|
||||
if (!expandedSet.has(current.node.id)) {
|
||||
// Expand it
|
||||
handleToggle(current.node.id)
|
||||
} else {
|
||||
// Move to first child (it will be the next visible node)
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault()
|
||||
if (!current) break
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (hasChildren && expandedSet.has(current.node.id)) {
|
||||
// Collapse
|
||||
handleToggle(current.node.id)
|
||||
} else if (current.parentId !== null) {
|
||||
// Move to parent
|
||||
focusNode(current.parentId)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
if (current) {
|
||||
onSelect?.(current.node.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Home': {
|
||||
e.preventDefault()
|
||||
if (visibleNodes.length > 0) {
|
||||
focusNode(visibleNodes[0].node.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'End': {
|
||||
e.preventDefault()
|
||||
if (visibleNodes.length > 0) {
|
||||
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
|
||||
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
|
||||
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
|
||||
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
|
||||
case 'Enter': { e.preventDefault(); if (current) onSelect?.(current.node.id); break }
|
||||
case 'Home': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[0].node.id); break }
|
||||
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -239,6 +230,10 @@ function TreeNodeRow({
|
||||
|
||||
return (
|
||||
<li role="none">
|
||||
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
|
||||
onKeyDown={handleKeyDown} which handles Enter (select) and all arrow keys
|
||||
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
|
||||
fire the action twice. */}
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
|
||||
@@ -32,12 +32,16 @@ export { MultiSelect } from './MultiSelect/MultiSelect'
|
||||
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||
export { Popover } from './Popover/Popover'
|
||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
||||
export type { RouteNode, NodeAction, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
|
||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||
export { SplitPane } from './SplitPane/SplitPane'
|
||||
export { Tabs } from './Tabs/Tabs'
|
||||
export { ToastProvider, useToast } from './Toast/Toast'
|
||||
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/CommandPaletteProvider'
|
||||
export * from './providers/GlobalFilterProvider'
|
||||
export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider'
|
||||
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
|
||||
export * from './utils/hashColor'
|
||||
export * from './utils/timePresets'
|
||||
export * from './utils/rechartsTheme'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main {
|
||||
|
||||
@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
|
||||
interface AppShellProps {
|
||||
sidebar: ReactNode
|
||||
children: ReactNode
|
||||
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
|
||||
detail?: ReactNode
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, children, detail }: AppShellProps) {
|
||||
export function AppShell({ sidebar, children }: AppShellProps) {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
{sidebar}
|
||||
<div className={styles.main}>
|
||||
{children}
|
||||
</div>
|
||||
{detail}
|
||||
<div id="cameleer-detail-panel-root" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,36 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
.sidebarCollapsed {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
/* Collapse toggle */
|
||||
.collapseToggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--sidebar-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
.collapseToggle:hover {
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
@@ -15,6 +45,12 @@
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .logo {
|
||||
padding: 16px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logoImg {
|
||||
@@ -106,71 +142,40 @@
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Scrollable nav area */
|
||||
.navArea {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.section {
|
||||
padding: 14px 12px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
/* Section icon (collapsed rail) */
|
||||
.sectionIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
color: var(--sidebar-muted);
|
||||
}
|
||||
|
||||
/* Items container */
|
||||
.items {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* Nav item (flat links like Dashboards) */
|
||||
.item {
|
||||
/* Rail item (collapsed sidebar section) */
|
||||
.sectionRailItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--sidebar-text);
|
||||
font-size: 13px;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
border-left: 3px solid transparent;
|
||||
margin-bottom: 1px;
|
||||
user-select: none;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
.sectionRailItem:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: #e8dfd4;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber);
|
||||
.sectionRailItemActive {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.navIcon {
|
||||
font-size: 14px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
color: var(--sidebar-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item.active .navIcon {
|
||||
.sectionRailItemActive .sectionIcon {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.routeArrow {
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
.treeSectionActive {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
/* Item sub-elements */
|
||||
@@ -200,15 +205,7 @@
|
||||
padding: 0 6px 6px;
|
||||
margin-bottom: 2px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.treeSectionLabel {
|
||||
padding: 10px 12px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--sidebar-muted);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
/* Collapsible section toggle */
|
||||
@@ -218,6 +215,8 @@
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 0 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.treeSectionChevronBtn {
|
||||
@@ -383,100 +382,13 @@
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ── Starred section ─────────────────────────────────────────────────────── */
|
||||
|
||||
.starredSection {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.starredHeader {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.starredList {
|
||||
padding: 0 6px 6px;
|
||||
}
|
||||
|
||||
.starredGroup {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.starredGroupLabel {
|
||||
padding: 4px 12px 2px;
|
||||
font-size: 10px;
|
||||
color: var(--sidebar-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.starredItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--sidebar-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.starredItem:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
.starredItemInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.starredItemName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.starredItemContext {
|
||||
font-size: 10px;
|
||||
color: var(--sidebar-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Remove button */
|
||||
.starredRemove {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
margin: 0;
|
||||
color: var(--sidebar-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.starredItem:hover .starredRemove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.starredRemove:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* ── Bottom links ────────────────────────────────────────────────────────── */
|
||||
|
||||
.bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.bottomItem {
|
||||
|
||||
@@ -1,172 +1,327 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { Sidebar, type SidebarApp } from './Sidebar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { ThemeProvider } from '../../providers/ThemeProvider'
|
||||
|
||||
const TEST_APPS: SidebarApp[] = [
|
||||
{
|
||||
id: 'order-service',
|
||||
name: 'order-service',
|
||||
health: 'live',
|
||||
exchangeCount: 1433,
|
||||
routes: [
|
||||
{ id: 'order-intake', name: 'order-intake', exchangeCount: 892 },
|
||||
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: 14.2 },
|
||||
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: 11.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'payment-svc',
|
||||
name: 'payment-svc',
|
||||
health: 'live',
|
||||
exchangeCount: 912,
|
||||
routes: [
|
||||
{ id: 'payment-process', name: 'payment-process', exchangeCount: 414 },
|
||||
],
|
||||
agents: [],
|
||||
},
|
||||
]
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) {
|
||||
return render(
|
||||
const LogoIcon = () => <svg data-testid="logo-icon" />
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<MemoryRouter>
|
||||
<Sidebar apps={TEST_APPS} {...props} />
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>,
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Sidebar compound component', () => {
|
||||
// 1. renders Header with logo, title, version
|
||||
it('renders Header with logo, title, and version', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('MyApp')).toBeInTheDocument()
|
||||
expect(screen.getByText('v1.2.3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the logo and brand name', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('cameleer')).toBeInTheDocument()
|
||||
expect(screen.getByText('v3.2.1')).toBeInTheDocument()
|
||||
// 2. hides Header title and version when collapsed
|
||||
it('hides Header title and version when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByText('MyApp')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('v1.2.3')).not.toBeInTheDocument()
|
||||
// Logo should still be visible
|
||||
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the search input', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument()
|
||||
// 3. renders Section with label and children
|
||||
it('renders Section with label and children when open', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>Section Child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Section Child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Navigation section header', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
||||
// 4. hides Section children when section collapsed (open=false)
|
||||
it('hides Section children when section is not open', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>Section Child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Section Child')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Applications tree section', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Applications')).toBeInTheDocument()
|
||||
// 5. calls onToggle when Section header clicked
|
||||
it('calls onToggle when Section chevron button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onToggle = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const btn = screen.getByRole('button', { name: /collapse settings/i })
|
||||
await user.click(btn)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders Agents tree section', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Agents')).toBeInTheDocument()
|
||||
// 6. renders collapse toggle and calls onCollapseToggle
|
||||
it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapseToggle = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar onCollapseToggle={onCollapseToggle}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i })
|
||||
await user.click(toggleBtn)
|
||||
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders Routes nav link', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Routes')).toBeInTheDocument()
|
||||
// 7. renders expand toggle label when collapsed
|
||||
it('renders expand toggle when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed onCollapseToggle={vi.fn()}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders bottom links', () => {
|
||||
renderSidebar()
|
||||
// 8. renders search input and calls onSearchChange
|
||||
it('renders search input and calls onSearchChange on input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSearchChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar searchValue="" onSearchChange={onSearchChange}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Filter...')
|
||||
expect(input).toBeInTheDocument()
|
||||
|
||||
await user.type(input, 'hello')
|
||||
expect(onSearchChange).toHaveBeenCalled()
|
||||
// Each keystroke fires once
|
||||
expect(onSearchChange.mock.calls[0][0]).toBe('h')
|
||||
})
|
||||
|
||||
// 9. hides search when collapsed
|
||||
it('hides search input when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed searchValue="" onSearchChange={vi.fn()}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 10. hides search when onSearchChange not provided
|
||||
it('hides search input when onSearchChange is not provided', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar searchValue="">
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 11. renders FooterLinks with icons and labels
|
||||
it('renders FooterLinks with icon and label', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={<span data-testid="footer-icon">ic</span>}
|
||||
label="Admin"
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByTestId('footer-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument()
|
||||
expect(screen.getByText('API Docs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders app names in the Applications tree', () => {
|
||||
renderSidebar()
|
||||
// order-service appears in Applications, Routes, and Agents trees
|
||||
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
// 12. hides FooterLink labels when collapsed and sets title tooltip
|
||||
it('hides FooterLink label when collapsed and exposes title tooltip', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={<span>ic</span>}
|
||||
label="Admin"
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
||||
|
||||
// The clickable element should carry a title attribute for tooltip
|
||||
// (accessible name comes from icon content when label is hidden)
|
||||
const item = screen.getByTitle('Admin')
|
||||
expect(item).toHaveAttribute('title', 'Admin')
|
||||
})
|
||||
|
||||
it('renders exchange count badges', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('1.4k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders agent live count badge in Agents tree', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('2/2 live')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show starred section when nothing is starred', () => {
|
||||
renderSidebar()
|
||||
expect(screen.queryByText('★ Starred')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows starred section after starring an item', async () => {
|
||||
// 13. calls FooterLink onClick
|
||||
it('calls FooterLink onClick when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
const onClick = vi.fn()
|
||||
|
||||
// Find the first app row (order-service in Applications tree) and hover to reveal star
|
||||
const appRows = screen.getAllByText('order-service')
|
||||
const appRow = appRows[0].closest('[role="treeitem"]')!
|
||||
await user.hover(appRow)
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// Click the star button
|
||||
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')!
|
||||
await user.click(starBtn)
|
||||
|
||||
expect(screen.getByText('★ Starred')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('Admin'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('filters tree items by search', async () => {
|
||||
// 14. renders Section as icon-rail item when sidebar collapsed
|
||||
it('renders Section as icon-rail item when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Section
|
||||
icon={<span data-testid="section-icon">ic</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// Label text should not be visible (only as tooltip via title attr)
|
||||
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
|
||||
|
||||
// Rail item carries title attribute for tooltip
|
||||
// (accessible name comes from icon content when label is hidden)
|
||||
const railItem = screen.getByTitle('Settings')
|
||||
expect(railItem).toHaveAttribute('title', 'Settings')
|
||||
|
||||
// Icon should still render
|
||||
expect(screen.getByTestId('section-icon')).toBeInTheDocument()
|
||||
|
||||
// Section children should not be rendered
|
||||
expect(screen.queryByText('child')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 15. fires both onCollapseToggle and onToggle when icon-rail section clicked
|
||||
it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
const onCollapseToggle = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Filter...')
|
||||
await user.type(searchInput, 'payment')
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed onCollapseToggle={onCollapseToggle}>
|
||||
<Sidebar.Section
|
||||
icon={<span>ic</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// payment-svc should still be visible (may appear in multiple trees)
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
const railItem = screen.getByTitle('Settings')
|
||||
await user.click(railItem)
|
||||
|
||||
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('expands tree to show children when chevron is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
// 16. applies active highlight to FooterLink
|
||||
it('applies active highlight class to FooterLink when active', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" active />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// Find the expand button for order-service in Applications tree
|
||||
const expandBtns = screen.getAllByLabelText('Expand')
|
||||
await user.click(expandBtns[0])
|
||||
|
||||
// Routes should now be visible
|
||||
expect(screen.getByText('order-intake')).toBeInTheDocument()
|
||||
expect(screen.getByText('order-enrichment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses expanded tree when chevron is clicked again', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
|
||||
const expandBtns = screen.getAllByLabelText('Expand')
|
||||
await user.click(expandBtns[0])
|
||||
expect(screen.getByText('order-intake')).toBeInTheDocument()
|
||||
|
||||
const collapseBtn = screen.getByLabelText('Collapse')
|
||||
await user.click(collapseBtn)
|
||||
expect(screen.queryByText('order-intake')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render apps with no agents in the Agents tree', () => {
|
||||
renderSidebar()
|
||||
// payment-svc has no agents, so it shouldn't appear under the Agents section header
|
||||
// But it still appears under Applications. Let's check the agent tree specifically.
|
||||
const agentBadges = screen.queryAllByText(/\/.*live/)
|
||||
// Only order-service should have an agent badge
|
||||
expect(agentBadges).toHaveLength(1)
|
||||
expect(agentBadges[0].textContent).toBe('2/2 live')
|
||||
const item = screen.getByText('Admin').closest('[role="button"]')!
|
||||
expect(item.className).toMatch(/bottomItemActive/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,563 +1,253 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { type ReactNode, Children, isValidElement } from 'react'
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-react'
|
||||
import styles from './Sidebar.module.css'
|
||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
||||
import { useStarred } from './useStarred'
|
||||
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
|
||||
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
// ── Sub-component props ─────────────────────────────────────────────────────
|
||||
|
||||
export interface SidebarApp {
|
||||
id: string
|
||||
name: string
|
||||
health: 'live' | 'stale' | 'dead'
|
||||
exchangeCount: number
|
||||
routes: SidebarRoute[]
|
||||
agents: SidebarAgent[]
|
||||
}
|
||||
|
||||
export interface SidebarRoute {
|
||||
id: string
|
||||
name: string
|
||||
exchangeCount: number
|
||||
}
|
||||
|
||||
export interface SidebarAgent {
|
||||
id: string
|
||||
name: string
|
||||
status: 'live' | 'stale' | 'dead'
|
||||
tps: number
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
apps: SidebarApp[]
|
||||
interface SidebarHeaderProps {
|
||||
logo: ReactNode
|
||||
title: string
|
||||
version?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps.map((app) => ({
|
||||
id: `app:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: formatCount(app.exchangeCount),
|
||||
path: `/apps/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: app.id,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `route:${app.id}:${route.id}`,
|
||||
starKey: `${app.id}:${route.id}`,
|
||||
label: route.name,
|
||||
icon: <span className={styles.routeArrow}>▸</span>,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.routes.length > 0)
|
||||
.map((app) => ({
|
||||
id: `routes:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: `${app.routes.length} routes`,
|
||||
path: `/routes/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `routes:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `routestat:${app.id}:${route.id}`,
|
||||
starKey: `routes:${app.id}:${route.id}`,
|
||||
label: route.name,
|
||||
icon: <span className={styles.routeArrow}>▸</span>,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.agents.length > 0)
|
||||
.map((app) => {
|
||||
const liveCount = app.agents.filter((a) => a.status === 'live').length
|
||||
return {
|
||||
id: `agents:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: `${liveCount}/${app.agents.length} live`,
|
||||
path: `/agents/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `agents:${app.id}`,
|
||||
children: app.agents.map((agent) => ({
|
||||
id: `agent:${app.id}:${agent.id}`,
|
||||
starKey: `${app.id}:${agent.id}`,
|
||||
label: agent.name,
|
||||
badge: `${agent.tps.toFixed(1)}/s`,
|
||||
path: `/agents/${app.id}/${agent.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Starred section helpers ──────────────────────────────────────────────────
|
||||
|
||||
interface StarredItem {
|
||||
starKey: string
|
||||
interface SidebarSectionProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
path: string
|
||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
||||
parentApp?: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
active?: boolean
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
|
||||
const items: StarredItem[] = []
|
||||
|
||||
for (const app of apps) {
|
||||
if (starredIds.has(app.id)) {
|
||||
items.push({
|
||||
starKey: app.id,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/apps/${app.id}`,
|
||||
type: 'application',
|
||||
})
|
||||
}
|
||||
for (const route of app.routes) {
|
||||
const key = `${app.id}:${route.id}`
|
||||
if (starredIds.has(key)) {
|
||||
items.push({
|
||||
starKey: key,
|
||||
label: route.name,
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
type: 'route',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
const agentsAppKey = `agents:${app.id}`
|
||||
if (starredIds.has(agentsAppKey)) {
|
||||
items.push({
|
||||
starKey: agentsAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/agents/${app.id}`,
|
||||
type: 'agent',
|
||||
})
|
||||
}
|
||||
for (const agent of app.agents) {
|
||||
const key = `${app.id}:${agent.id}`
|
||||
if (starredIds.has(key)) {
|
||||
items.push({
|
||||
starKey: key,
|
||||
label: agent.name,
|
||||
path: `/agents/${app.id}/${agent.id}`,
|
||||
type: 'agent',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Routes tree starred items
|
||||
const routesAppKey = `routes:${app.id}`
|
||||
if (starredIds.has(routesAppKey)) {
|
||||
items.push({
|
||||
starKey: routesAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/routes/${app.id}`,
|
||||
type: 'routestat',
|
||||
})
|
||||
}
|
||||
for (const route of app.routes) {
|
||||
const routeKey = `routes:${app.id}:${route.id}`
|
||||
if (starredIds.has(routeKey)) {
|
||||
items.push({
|
||||
starKey: routeKey,
|
||||
label: route.name,
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
type: 'routestat',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
interface SidebarFooterProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ── StarredGroup ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StarredGroup({
|
||||
label,
|
||||
items,
|
||||
onNavigate,
|
||||
onRemove,
|
||||
}: {
|
||||
interface SidebarFooterLinkProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
items: StarredItem[]
|
||||
onNavigate: (path: string) => void
|
||||
onRemove: (starKey: string) => void
|
||||
}) {
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SidebarRootProps {
|
||||
collapsed?: boolean
|
||||
onCollapseToggle?: () => void
|
||||
searchValue?: string
|
||||
onSearchChange?: (query: string) => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function SidebarHeader({ logo, title, version, onClick, className }: SidebarHeaderProps) {
|
||||
const { collapsed } = useSidebarContext()
|
||||
|
||||
return (
|
||||
<div className={styles.starredGroup}>
|
||||
<div className={styles.starredGroupLabel}>{label}</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.starKey}
|
||||
className={styles.starredItem}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
|
||||
>
|
||||
{item.icon}
|
||||
<div className={styles.starredItemInfo}>
|
||||
<span className={styles.starredItemName}>{item.label}</span>
|
||||
{item.parentApp && (
|
||||
<span className={styles.starredItemContext}>{item.parentApp}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={styles.starredRemove}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${item.label} from starred`}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={`${styles.logo} ${className ?? ''}`}
|
||||
onClick={onClick}
|
||||
style={onClick ? { cursor: 'pointer' } : undefined}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||
>
|
||||
{logo}
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<span className={styles.brand}>{title}</span>
|
||||
{version && <span className={styles.version}>{version}</span>}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
function SidebarSection({
|
||||
icon,
|
||||
label,
|
||||
open,
|
||||
onToggle,
|
||||
active,
|
||||
children,
|
||||
className,
|
||||
}: SidebarSectionProps) {
|
||||
const { collapsed, onCollapseToggle } = useSidebarContext()
|
||||
|
||||
export function Sidebar({ apps, className }: SidebarProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
|
||||
|
||||
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setAppsCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setAgentsCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setRoutesCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||
|
||||
// Build tree data
|
||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
||||
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
|
||||
|
||||
// Sidebar reveal from Cmd-K navigation (passed via location state)
|
||||
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarRevealPath) return
|
||||
|
||||
// Uncollapse Applications section if reveal path matches an apps tree node
|
||||
const matchesAppTree = appNodes.some((node) =>
|
||||
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||
// In icon-rail (collapsed) mode, render a centered icon with tooltip
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
// Expand sidebar and open the section
|
||||
onCollapseToggle?.()
|
||||
onToggle()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onCollapseToggle?.()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.sectionIcon}>{icon}</span>
|
||||
</div>
|
||||
)
|
||||
if (matchesAppTree && appsCollapsed) {
|
||||
_setAppsCollapsed(false)
|
||||
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
|
||||
}
|
||||
|
||||
// Uncollapse Agents section if reveal path matches an agents tree node
|
||||
const matchesAgentTree = agentNodes.some((node) =>
|
||||
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||
)
|
||||
if (matchesAgentTree && agentsCollapsed) {
|
||||
_setAgentsCollapsed(false)
|
||||
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
|
||||
}
|
||||
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Build starred items
|
||||
const starredItems = useMemo(
|
||||
() => collectStarredItems(apps, starredIds),
|
||||
[apps, starredIds],
|
||||
)
|
||||
|
||||
const starredApps = starredItems.filter((i) => i.type === 'application')
|
||||
const starredRoutes = starredItems.filter((i) => i.type === 'route')
|
||||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||||
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
|
||||
const hasStarred = starredItems.length > 0
|
||||
|
||||
// For exchange detail pages, use the reveal path for sidebar selection so
|
||||
// the parent route is highlighted (exchanges have no sidebar entry of their own)
|
||||
const effectiveSelectedPath = location.pathname.startsWith('/exchanges/') && sidebarRevealPath
|
||||
? sidebarRevealPath
|
||||
: location.pathname
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||
{/* Logo */}
|
||||
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
|
||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
||||
<div>
|
||||
<span className={styles.brand}>cameleer</span>
|
||||
<span className={styles.version}>v3.2.1</span>
|
||||
</div>
|
||||
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
|
||||
<div
|
||||
className={styles.treeSectionToggle}
|
||||
onClick={onToggle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }}
|
||||
>
|
||||
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
||||
<span className={styles.treeSectionLabel}>{label}</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className={styles.searchWrap}>
|
||||
<div className={styles.searchInner}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchClear}
|
||||
onClick={() => setSearch('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation (scrollable) — includes starred section */}
|
||||
<div className={styles.navArea}>
|
||||
<div className={styles.section}>Navigation</div>
|
||||
|
||||
{/* Applications tree (collapsible, label navigates to /apps) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setAppsCollapsed((v) => !v)}
|
||||
aria-expanded={!appsCollapsed}
|
||||
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
||||
>
|
||||
{appsCollapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => navigate('/apps')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
|
||||
>
|
||||
Applications
|
||||
</span>
|
||||
</div>
|
||||
{!appsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={appNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:apps"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agents tree (collapsible, label navigates to /agents) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setAgentsCollapsed((v) => !v)}
|
||||
aria-expanded={!agentsCollapsed}
|
||||
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
||||
>
|
||||
{agentsCollapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => navigate('/agents')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
|
||||
>
|
||||
Agents
|
||||
</span>
|
||||
</div>
|
||||
{!agentsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={agentNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:agents"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Routes tree (collapsible, label navigates to /routes) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setRoutesCollapsed((v) => !v)}
|
||||
aria-expanded={!routesCollapsed}
|
||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||
>
|
||||
{routesCollapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => navigate('/routes')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
||||
>
|
||||
Routes
|
||||
</span>
|
||||
</div>
|
||||
{!routesCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={routeNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:routes"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No results message */}
|
||||
{search && appNodes.length === 0 && agentNodes.length === 0 && (
|
||||
<div className={styles.noResults}>No results</div>
|
||||
)}
|
||||
|
||||
{/* Starred section (inside scrollable area, hidden when empty) */}
|
||||
{hasStarred && (
|
||||
<div className={styles.starredSection}>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.starredHeader}>★ Starred</span>
|
||||
</div>
|
||||
<div className={styles.starredList}>
|
||||
{starredApps.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Applications"
|
||||
items={starredApps}
|
||||
onNavigate={navigate}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredRoutes.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRoutes}
|
||||
onNavigate={navigate}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredAgents.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Agents"
|
||||
items={starredAgents}
|
||||
onNavigate={navigate}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredRouteStats.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRouteStats}
|
||||
onNavigate={navigate}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom links */}
|
||||
<div className={styles.bottom}>
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => navigate('/admin')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
|
||||
>
|
||||
<span className={styles.bottomIcon}>⚙</span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>Admin</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => navigate('/api-docs')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
|
||||
>
|
||||
<span className={styles.bottomIcon}>☰</span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>API Docs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{open && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ children, className }: SidebarFooterProps) {
|
||||
return (
|
||||
<div className={`${styles.bottom} ${className ?? ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooterLink({ icon, label, active, onClick, className }: SidebarFooterLinkProps) {
|
||||
const { collapsed } = useSidebarContext()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
active ? styles.bottomItemActive : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={collapsed ? label : undefined}
|
||||
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||
>
|
||||
<span className={styles.bottomIcon}>{icon}</span>
|
||||
{!collapsed && (
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>{label}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Root component ──────────────────────────────────────────────────────────
|
||||
|
||||
function SidebarRoot({
|
||||
collapsed = false,
|
||||
onCollapseToggle,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
children,
|
||||
className,
|
||||
}: SidebarRootProps) {
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed, onCollapseToggle }}>
|
||||
<aside
|
||||
className={[
|
||||
styles.sidebar,
|
||||
collapsed ? styles.sidebarCollapsed : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{/* Collapse toggle */}
|
||||
{onCollapseToggle && (
|
||||
<button
|
||||
className={styles.collapseToggle}
|
||||
onClick={onCollapseToggle}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronsRight size={14} /> : <ChevronsLeft size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Render Header first, then search, then remaining children */}
|
||||
{(() => {
|
||||
const childArray = Children.toArray(children)
|
||||
const headerIdx = childArray.findIndex(
|
||||
(child) => isValidElement(child) && child.type === SidebarHeader,
|
||||
)
|
||||
const header = headerIdx >= 0 ? childArray[headerIdx] : null
|
||||
const rest = headerIdx >= 0
|
||||
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
|
||||
: childArray
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{onSearchChange && !collapsed && (
|
||||
<div className={styles.searchWrap}>
|
||||
<div className={styles.searchInner}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<Search size={12} />
|
||||
</span>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchClear}
|
||||
onClick={() => onSearchChange('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{rest}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</aside>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Compound export ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Sidebar = Object.assign(SidebarRoot, {
|
||||
Header: SidebarHeader,
|
||||
Section: SidebarSection,
|
||||
Footer: SidebarFooter,
|
||||
FooterLink: SidebarFooterLink,
|
||||
})
|
||||
|
||||
14
src/design-system/layout/Sidebar/SidebarContext.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export interface SidebarContextValue {
|
||||
collapsed: boolean
|
||||
onCollapseToggle?: () => void
|
||||
}
|
||||
|
||||
export const SidebarContext = createContext<SidebarContextValue>({
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
export function useSidebarContext(): SidebarContextValue {
|
||||
return useContext(SidebarContext)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type MouseEvent,
|
||||
} from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
|
||||
import styles from './Sidebar.module.css'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -33,24 +34,17 @@ export interface SidebarTreeProps {
|
||||
filterQuery?: string
|
||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
||||
onNavigate?: (path: string) => void
|
||||
}
|
||||
|
||||
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
||||
// ── Star icons ───────────────────────────────────────────────────────────────
|
||||
|
||||
function StarOutline() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
)
|
||||
return <Star size={14} />
|
||||
}
|
||||
|
||||
function StarFilled() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
)
|
||||
return <Star size={14} fill="currentColor" />
|
||||
}
|
||||
|
||||
// ── Persistent expand state ──────────────────────────────────────────────────
|
||||
@@ -130,6 +124,52 @@ function filterNodes(
|
||||
return { filtered: walk(nodes), matchedParentIds }
|
||||
}
|
||||
|
||||
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
}
|
||||
|
||||
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||
const prev = visibleNodes[currentIndex - 1]
|
||||
if (prev) focusNode(prev.node.id)
|
||||
}
|
||||
|
||||
function handleArrowRight(
|
||||
current: FlatNode | undefined,
|
||||
currentIndex: number,
|
||||
expandedSet: Set<string>,
|
||||
visibleNodes: FlatNode[],
|
||||
handleToggle: (id: string) => void,
|
||||
focusNode: (id: string) => void,
|
||||
) {
|
||||
if (!current) return
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (!hasChildren) return
|
||||
if (!expandedSet.has(current.node.id)) {
|
||||
handleToggle(current.node.id)
|
||||
} else {
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleArrowLeft(
|
||||
current: FlatNode | undefined,
|
||||
expandedSet: Set<string>,
|
||||
handleToggle: (id: string) => void,
|
||||
focusNode: (id: string) => void,
|
||||
) {
|
||||
if (!current) return
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (hasChildren && expandedSet.has(current.node.id)) {
|
||||
handleToggle(current.node.id)
|
||||
} else if (current.parentId !== null) {
|
||||
focusNode(current.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SidebarTree ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function SidebarTree({
|
||||
@@ -141,8 +181,10 @@ export function SidebarTree({
|
||||
filterQuery,
|
||||
persistKey,
|
||||
autoRevealPath,
|
||||
onNavigate,
|
||||
}: SidebarTreeProps) {
|
||||
const navigate = useNavigate()
|
||||
const routerNavigate = useNavigate()
|
||||
const navigate = onNavigate ?? routerNavigate
|
||||
|
||||
// Expand/collapse state — optionally persisted to sessionStorage
|
||||
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
||||
@@ -226,64 +268,13 @@ export function SidebarTree({
|
||||
const current = visibleNodes[currentIndex]
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
const prev = visibleNodes[currentIndex - 1]
|
||||
if (prev) focusNode(prev.node.id)
|
||||
break
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault()
|
||||
if (!current) break
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (hasChildren) {
|
||||
if (!expandedSet.has(current.node.id)) {
|
||||
handleToggle(current.node.id)
|
||||
} else {
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault()
|
||||
if (!current) break
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (hasChildren && expandedSet.has(current.node.id)) {
|
||||
handleToggle(current.node.id)
|
||||
} else if (current.parentId !== null) {
|
||||
focusNode(current.parentId)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
if (current?.node.path) {
|
||||
navigate(current.node.path)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Home': {
|
||||
e.preventDefault()
|
||||
if (visibleNodes.length > 0) {
|
||||
focusNode(visibleNodes[0].node.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'End': {
|
||||
e.preventDefault()
|
||||
if (visibleNodes.length > 0) {
|
||||
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
|
||||
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
|
||||
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
|
||||
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
|
||||
case 'Enter': { e.preventDefault(); if (current?.node.path) navigate(current.node.path); break }
|
||||
case 'Home': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[0].node.id); break }
|
||||
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -375,6 +366,10 @@ function SidebarTreeRow({
|
||||
|
||||
return (
|
||||
<li role="none">
|
||||
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
|
||||
onKeyDown={handleKeyDown} which handles Enter (navigate) and all arrow keys
|
||||
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
|
||||
fire the action twice. */}
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
@@ -395,7 +390,7 @@ function SidebarTreeRow({
|
||||
tabIndex={-1}
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -81,6 +81,56 @@
|
||||
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 {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
@@ -103,14 +153,17 @@
|
||||
}
|
||||
|
||||
.env {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
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;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
border: 1px solid var(--success-border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Search, Moon, Sun, Power } from 'lucide-react'
|
||||
import styles from './TopBar.module.css'
|
||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||
@@ -8,15 +10,12 @@ import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeD
|
||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
||||
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||
import { useTheme } from '../../providers/ThemeProvider'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||
|
||||
interface TopBarProps {
|
||||
breadcrumb: BreadcrumbItem[]
|
||||
environment?: string
|
||||
environment?: ReactNode
|
||||
user?: { name: string }
|
||||
onLogout?: () => void
|
||||
className?: string
|
||||
@@ -39,11 +38,12 @@ export function TopBar({
|
||||
const globalFilters = useGlobalFilters()
|
||||
const commandPalette = useCommandPalette()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const breadcrumbOverride = useBreadcrumbOverride()
|
||||
|
||||
return (
|
||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||
{/* Left: Breadcrumb */}
|
||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
||||
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
@@ -53,10 +53,7 @@ export function TopBar({
|
||||
aria-label="Open search"
|
||||
>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<Search size={13} />
|
||||
</span>
|
||||
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
@@ -84,8 +81,18 @@ export function TopBar({
|
||||
onChange={globalFilters.setTimeRange}
|
||||
/>
|
||||
|
||||
{/* Right: theme toggle, env badge, user */}
|
||||
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
|
||||
<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 ? 'AUTO' : 'MANUAL'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
onClick={toggleTheme}
|
||||
@@ -93,10 +100,10 @@ export function TopBar({
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? '\u263E' : '\u2600'}
|
||||
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
||||
</button>
|
||||
{environment && (
|
||||
<span className={styles.env}>{environment}</span>
|
||||
<div className={styles.env}>{environment}</div>
|
||||
)}
|
||||
{user && (
|
||||
<Dropdown
|
||||
@@ -107,7 +114,7 @@ export function TopBar({
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
|
||||
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export { AppShell } from './AppShell/AppShell'
|
||||
export { Sidebar } from './Sidebar/Sidebar'
|
||||
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
|
||||
export { SidebarTree } from './Sidebar/SidebarTree'
|
||||
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||
export { useStarred } from './Sidebar/useStarred'
|
||||
export { TopBar } from './TopBar/TopBar'
|
||||
|
||||
@@ -46,24 +46,23 @@ describe('Alert', () => {
|
||||
})
|
||||
|
||||
it('shows default icon for each variant', () => {
|
||||
const { rerender } = render(<Alert variant="info">msg</Alert>)
|
||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
||||
const { container, rerender } = render(<Alert variant="info">msg</Alert>)
|
||||
// Each variant should render an SVG icon in the icon slot
|
||||
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
|
||||
rerender(<Alert variant="success">msg</Alert>)
|
||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
|
||||
rerender(<Alert variant="warning">msg</Alert>)
|
||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
|
||||
rerender(<Alert variant="error">msg</Alert>)
|
||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a custom icon when provided', () => {
|
||||
render(<Alert icon={<span>★</span>}>Custom icon alert</Alert>)
|
||||
expect(screen.getByText('★')).toBeInTheDocument()
|
||||
// Default icon should not appear
|
||||
expect(screen.queryByText('ℹ')).not.toBeInTheDocument()
|
||||
render(<Alert icon={<span data-testid="custom-icon">★</span>}>Custom icon alert</Alert>)
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dismiss button when dismissible is false', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||
import styles from './Alert.module.css'
|
||||
|
||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||
@@ -13,11 +14,11 @@ interface AlertProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICONS: Record<AlertVariant, string> = {
|
||||
info: 'ℹ',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✕',
|
||||
const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
|
||||
info: <Info size={16} />,
|
||||
success: <CheckCircle size={16} />,
|
||||
warning: <AlertTriangle size={16} />,
|
||||
error: <XCircle size={16} />,
|
||||
}
|
||||
|
||||
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
||||
@@ -61,7 +62,7 @@ export function Alert({
|
||||
aria-label="Dismiss alert"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,15 @@ export function InlineEdit({ value, onSave, placeholder, disabled, className }:
|
||||
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
||||
<span
|
||||
className={isEmpty ? styles.placeholder : styles.value}
|
||||
role="button"
|
||||
tabIndex={disabled ? undefined : 0}
|
||||
onClick={startEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
startEdit()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEmpty ? placeholder : value}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import styles from './Input.module.css'
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
icon?: ReactNode
|
||||
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
onClick={onClear}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
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'
|
||||
|
||||
export interface TimeRange {
|
||||
@@ -12,10 +12,13 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
||||
interface GlobalFilterContextValue {
|
||||
timeRange: TimeRange
|
||||
setTimeRange: (range: TimeRange) => void
|
||||
refreshTimeRange: () => void
|
||||
statusFilters: Set<ExchangeStatus>
|
||||
toggleStatus: (status: ExchangeStatus) => void
|
||||
clearStatusFilters: () => void
|
||||
isInTimeRange: (timestamp: Date) => boolean
|
||||
autoRefresh: boolean
|
||||
setAutoRefresh: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||
@@ -27,9 +30,17 @@ function getDefaultTimeRange(): TimeRange {
|
||||
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 }) {
|
||||
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
||||
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
||||
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
|
||||
|
||||
const setTimeRange = useCallback((range: TimeRange) => {
|
||||
setTimeRangeState(range)
|
||||
@@ -51,6 +62,29 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
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])
|
||||
|
||||
// Recompute time range from preset on demand (for manual refresh in PAUSED mode)
|
||||
const refreshTimeRange = useCallback(() => {
|
||||
if (timeRange.preset) {
|
||||
const { start, end } = computePresetRange(timeRange.preset)
|
||||
setTimeRangeState({ start, end, preset: timeRange.preset })
|
||||
}
|
||||
}, [timeRange.preset])
|
||||
|
||||
const isInTimeRange = useCallback(
|
||||
(timestamp: Date) => {
|
||||
if (timeRange.preset) {
|
||||
@@ -65,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<GlobalFilterContext.Provider
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
|
||||
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||
>
|
||||
{children}
|
||||
</GlobalFilterContext.Provider>
|
||||
|
||||
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
|
||||
451
src/layout/LayoutShell.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import { useState, useEffect, useMemo, type ReactNode } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, X } from 'lucide-react'
|
||||
import { AppShell } from '../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
|
||||
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
|
||||
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
|
||||
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
|
||||
import { StatusDot } from '../design-system/primitives/StatusDot/StatusDot'
|
||||
import { SIDEBAR_APPS } from '../mocks/sidebar'
|
||||
import type { SidebarApp } from '../mocks/sidebar'
|
||||
import camelLogoUrl from '../assets/camel-logo.svg'
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
// ── Tree node builders ──────────────────────────────────────────────────────
|
||||
|
||||
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps.map((app) => ({
|
||||
id: app.id,
|
||||
label: app.name,
|
||||
icon: <StatusDot status={app.health} />,
|
||||
badge: formatCount(app.exchangeCount),
|
||||
path: `/apps/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `app:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `${app.id}/${route.id}`,
|
||||
label: route.name,
|
||||
icon: <ChevronRight size={12} />,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.routes.length > 0)
|
||||
.map((app) => ({
|
||||
id: `routes:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot status={app.health} />,
|
||||
badge: `${app.routes.length} route${app.routes.length !== 1 ? 's' : ''}`,
|
||||
path: `/routes/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `routestat:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `routes:${app.id}/${route.id}`,
|
||||
label: route.name,
|
||||
icon: <ChevronRight size={12} />,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.agents.length > 0)
|
||||
.map((app) => {
|
||||
const liveCount = app.agents.filter((a) => a.status === 'live').length
|
||||
return {
|
||||
id: `agents:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot status={app.health} />,
|
||||
badge: `${liveCount}/${app.agents.length} live`,
|
||||
path: `/agents/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `agent:${app.id}`,
|
||||
children: app.agents.map((agent) => ({
|
||||
id: `agents:${app.id}/${agent.id}`,
|
||||
label: agent.name,
|
||||
icon: <StatusDot status={agent.status} />,
|
||||
badge: `${agent.tps} tps`,
|
||||
path: `/agents/${app.id}/${agent.id}`,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Starred items ───────────────────────────────────────────────────────────
|
||||
|
||||
interface StarredItem {
|
||||
starKey: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
path: string
|
||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
||||
parentApp?: string
|
||||
}
|
||||
|
||||
function collectStarredItems(
|
||||
apps: SidebarApp[],
|
||||
starredIds: Set<string>,
|
||||
): StarredItem[] {
|
||||
const items: StarredItem[] = []
|
||||
|
||||
for (const app of apps) {
|
||||
if (starredIds.has(`app:${app.id}`)) {
|
||||
items.push({
|
||||
starKey: `app:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <Box size={12} />,
|
||||
path: `/apps/${app.id}`,
|
||||
type: 'application',
|
||||
})
|
||||
}
|
||||
|
||||
for (const route of app.routes) {
|
||||
if (starredIds.has(`route:${app.id}/${route.id}`)) {
|
||||
items.push({
|
||||
starKey: `route:${app.id}/${route.id}`,
|
||||
label: route.name,
|
||||
icon: <ChevronRight size={12} />,
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
type: 'route',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (starredIds.has(`routestat:${app.id}`)) {
|
||||
items.push({
|
||||
starKey: `routestat:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <GitBranch size={12} />,
|
||||
path: `/routes/${app.id}`,
|
||||
type: 'routestat',
|
||||
})
|
||||
}
|
||||
|
||||
if (starredIds.has(`agent:${app.id}`)) {
|
||||
items.push({
|
||||
starKey: `agent:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <Cpu size={12} />,
|
||||
path: `/agents/${app.id}`,
|
||||
type: 'agent',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// ── Starred group component ─────────────────────────────────────────────────
|
||||
|
||||
interface StarredGroupProps {
|
||||
label: string
|
||||
items: StarredItem[]
|
||||
onRemove: (starKey: string) => void
|
||||
onNavigate: (path: string) => void
|
||||
}
|
||||
|
||||
function StarredGroup({ label, items, onRemove, onNavigate }: StarredGroupProps) {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'var(--text-tertiary)',
|
||||
padding: '4px 12px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.starKey}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '4px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path)
|
||||
}}
|
||||
>
|
||||
{item.icon && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', color: 'var(--text-tertiary)' }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.label}
|
||||
{item.parentApp && (
|
||||
<span style={{ color: 'var(--text-tertiary)', marginLeft: 4, fontSize: 10 }}>
|
||||
{item.parentApp}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 2,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(item.starKey)
|
||||
}}
|
||||
aria-label={`Remove ${item.label} from starred`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── localStorage-backed section collapse ────────────────────────────────────
|
||||
|
||||
function usePersistedCollapse(key: string, defaultValue: boolean): [boolean, () => void] {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw !== null) return raw === 'true'
|
||||
} catch { /* ignore */ }
|
||||
return defaultValue
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
setValue((prev) => {
|
||||
const next = !prev
|
||||
try {
|
||||
localStorage.setItem(key, String(next))
|
||||
} catch { /* ignore */ }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return [value, toggle]
|
||||
}
|
||||
|
||||
// ── LayoutShell ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function LayoutShell() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
|
||||
// Section collapse state — persisted to localStorage
|
||||
const [appsCollapsed, toggleAppsCollapsed] = usePersistedCollapse('cameleer:sidebar:apps-collapsed', false)
|
||||
const [agentsCollapsed, toggleAgentsCollapsed] = usePersistedCollapse('cameleer:sidebar:agents-collapsed', false)
|
||||
const [routesCollapsed, toggleRoutesCollapsed] = usePersistedCollapse('cameleer:sidebar:routes-collapsed', false)
|
||||
|
||||
// Tree data — static, so empty deps
|
||||
const appNodes = useMemo(() => buildAppTreeNodes(SIDEBAR_APPS), [])
|
||||
const agentNodes = useMemo(() => buildAgentTreeNodes(SIDEBAR_APPS), [])
|
||||
const routeNodes = useMemo(() => buildRouteTreeNodes(SIDEBAR_APPS), [])
|
||||
|
||||
// Sidebar reveal from Cmd-K navigation
|
||||
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||
|
||||
// Auto-uncollapse matching sections when sidebarRevealPath changes
|
||||
useEffect(() => {
|
||||
if (!sidebarRevealPath) return
|
||||
|
||||
if (sidebarRevealPath.startsWith('/apps') && appsCollapsed) {
|
||||
toggleAppsCollapsed()
|
||||
}
|
||||
if (sidebarRevealPath.startsWith('/agents') && agentsCollapsed) {
|
||||
toggleAgentsCollapsed()
|
||||
}
|
||||
if (sidebarRevealPath.startsWith('/routes') && routesCollapsed) {
|
||||
toggleRoutesCollapsed()
|
||||
}
|
||||
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname
|
||||
|
||||
// Starred items — collected and grouped
|
||||
const allStarred = useMemo(
|
||||
() => collectStarredItems(SIDEBAR_APPS, starredIds),
|
||||
[starredIds],
|
||||
)
|
||||
|
||||
const starredApps = allStarred.filter((s) => s.type === 'application')
|
||||
const starredRoutes = allStarred.filter((s) => s.type === 'route')
|
||||
const starredAgents = allStarred.filter((s) => s.type === 'agent')
|
||||
const starredRouteStats = allStarred.filter((s) => s.type === 'routestat')
|
||||
const hasStarred = allStarred.length > 0
|
||||
|
||||
const camelLogo = (
|
||||
<img
|
||||
src={camelLogoUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 24,
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapseToggle={() => setSidebarCollapsed((c) => !c)}
|
||||
searchValue={filterQuery}
|
||||
onSearchChange={setFilterQuery}
|
||||
>
|
||||
<Sidebar.Header
|
||||
logo={camelLogo}
|
||||
title="cameleer"
|
||||
version="v3.2.1"
|
||||
onClick={() => navigate('/apps')}
|
||||
/>
|
||||
|
||||
<Sidebar.Section
|
||||
label="Applications"
|
||||
icon={<Box size={14} />}
|
||||
open={!appsCollapsed}
|
||||
onToggle={toggleAppsCollapsed}
|
||||
active={location.pathname.startsWith('/apps')}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={appNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="cameleer:expanded:apps"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
label="Agents"
|
||||
icon={<Cpu size={14} />}
|
||||
open={!agentsCollapsed}
|
||||
onToggle={toggleAgentsCollapsed}
|
||||
active={location.pathname.startsWith('/agents')}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={agentNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="cameleer:expanded:agents"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
label="Routes"
|
||||
icon={<GitBranch size={14} />}
|
||||
open={!routesCollapsed}
|
||||
onToggle={toggleRoutesCollapsed}
|
||||
active={location.pathname.startsWith('/routes')}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={routeNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="cameleer:expanded:routes"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
{hasStarred && (
|
||||
<Sidebar.Section
|
||||
label="\u2605 Starred"
|
||||
icon={<span />}
|
||||
open={true}
|
||||
onToggle={() => {}}
|
||||
active={false}
|
||||
>
|
||||
<StarredGroup
|
||||
label="Applications"
|
||||
items={starredApps}
|
||||
onRemove={toggleStar}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRoutes}
|
||||
onRemove={toggleStar}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<StarredGroup
|
||||
label="Route Stats"
|
||||
items={starredRouteStats}
|
||||
onRemove={toggleStar}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<StarredGroup
|
||||
label="Agents"
|
||||
items={starredAgents}
|
||||
onRemove={toggleStar}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
)}
|
||||
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={<Settings size={14} />}
|
||||
label="Admin"
|
||||
onClick={() => navigate('/admin')}
|
||||
active={location.pathname.startsWith('/admin')}
|
||||
/>
|
||||
<Sidebar.FooterLink
|
||||
icon={<FileText size={14} />}
|
||||
label="API Docs"
|
||||
onClick={() => navigate('/api-docs')}
|
||||
active={location.pathname === '/api-docs'}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -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,9 +1,6 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import styles from './Admin.module.css'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
@@ -23,7 +20,7 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Admin', href: '/admin' },
|
||||
@@ -40,6 +37,6 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
|
||||
<div className={styles.adminContent}>
|
||||
{children}
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import styles from './AgentHealth.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -27,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
||||
|
||||
// Mock data
|
||||
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import { agentEvents } from '../../mocks/agentEvents'
|
||||
|
||||
// ── URL scope parsing ────────────────────────────────────────────────────────
|
||||
@@ -316,19 +314,7 @@ export function AgentHealth() {
|
||||
const isFullWidth = scope.level !== 'all'
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
|
||||
detail={
|
||||
selectedInstance ? (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={selectedInstance.name}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={buildBreadcrumb(scope)}
|
||||
environment="PRODUCTION"
|
||||
@@ -389,7 +375,7 @@ export function AgentHealth() {
|
||||
{scope.level !== 'all' && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -453,6 +439,16 @@ export function AgentHealth() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
{/* Detail panel (portals itself) */}
|
||||
{selectedInstance && (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={selectedInstance.name}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import styles from './AgentInstance.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -27,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
||||
|
||||
// Data
|
||||
import { agents } from '../../mocks/agents'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import { agentEvents } from '../../mocks/agentEvents'
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -126,12 +124,12 @@ export function AgentInstance() {
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} />
|
||||
<div className={styles.content}>
|
||||
<div className={styles.notFound}>Agent instance not found.</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,7 +150,7 @@ export function AgentInstance() {
|
||||
const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
|
||||
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
@@ -177,9 +175,9 @@ export function AgentInstance() {
|
||||
{/* Scope trail + badges */}
|
||||
<div className={styles.scopeTrail}>
|
||||
<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>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
||||
<Badge label={agent.version} color="auto" variant="outlined" />
|
||||
@@ -301,6 +299,6 @@ export function AgentInstance() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
export function ApiDocs() {
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[{ label: 'API Documentation' }]}
|
||||
environment="PRODUCTION"
|
||||
@@ -17,6 +14,6 @@ export function ApiDocs() {
|
||||
title="API Documentation"
|
||||
description="API documentation coming soon."
|
||||
/>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
export function AppDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
@@ -23,6 +20,6 @@ export function AppDetail() {
|
||||
title="Application Detail"
|
||||
description="Application detail view coming soon."
|
||||
/>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--bg-body);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Filter bar spacing */
|
||||
@@ -19,6 +22,10 @@
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||
import styles from './Dashboard.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -43,10 +42,10 @@ const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
|
||||
warning: 'var(--warning)',
|
||||
}
|
||||
|
||||
const TREND_ICONS: Record<KpiMetric['trend'], string> = {
|
||||
up: '\u2191',
|
||||
down: '\u2193',
|
||||
neutral: '\u2192',
|
||||
const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
|
||||
up: <TrendingUp size={12} />,
|
||||
down: <TrendingDown size={12} />,
|
||||
neutral: <ArrowRight size={12} />,
|
||||
}
|
||||
|
||||
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
||||
@@ -60,7 +59,7 @@ function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' |
|
||||
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
||||
label: m.label,
|
||||
value: m.unit ? `${m.value} ${m.unit}` : m.value,
|
||||
trend: { label: `${TREND_ICONS[m.trend]} ${m.trendValue}`, variant: sentimentToVariant(m.trendSentiment) },
|
||||
trend: { label: <><span style={{ display: 'inline-flex', verticalAlign: 'middle' }}>{TREND_ICONS[m.trend]}</span> {m.trendValue}</>, variant: sentimentToVariant(m.trendSentiment) },
|
||||
subtitle: m.detail,
|
||||
sparkline: m.sparkline,
|
||||
borderColor: ACCENT_TO_COLOR[m.accent],
|
||||
@@ -206,7 +205,7 @@ export function Dashboard() {
|
||||
navigate(`/exchanges/${row.id}`)
|
||||
}}
|
||||
>
|
||||
↗
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
),
|
||||
}
|
||||
@@ -286,106 +285,7 @@ export function Dashboard() {
|
||||
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
detail={
|
||||
selectedExchange ? (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={`${selectedExchange.orderId} — ${selectedExchange.route}`}
|
||||
>
|
||||
{/* Link to full detail page */}
|
||||
<div className={styles.panelSection}>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
||||
>
|
||||
Open full details →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Overview</div>
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(selectedExchange.status)} />
|
||||
<span>{statusLabel(selectedExchange.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Duration</span>
|
||||
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Route</span>
|
||||
<span>{selectedExchange.route}</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Customer</span>
|
||||
<MonoText size="sm">{selectedExchange.customer}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Agent</span>
|
||||
<MonoText size="sm">{selectedExchange.agent}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Correlation</span>
|
||||
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Timestamp</span>
|
||||
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{totalErrors > 0 && (
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>
|
||||
Errors
|
||||
{totalErrors > 1 && (
|
||||
<Badge label={`+${totalErrors - 1} more`} color="error" variant="outlined" />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.errorBlock}>
|
||||
<div className={styles.errorClass}>
|
||||
{selectedExchange.errorClass ?? processorErrors[0]?.name}
|
||||
</div>
|
||||
<div className={styles.errorMessage}>
|
||||
{selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Flow */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||
<RouteFlow nodes={routeNodes} />
|
||||
</div>
|
||||
|
||||
{/* Processor Timeline */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>
|
||||
Processor Timeline
|
||||
<span className={styles.panelSectionMeta}>{formatDuration(selectedExchange.durationMs)}</span>
|
||||
</div>
|
||||
<ProcessorTimeline
|
||||
processors={selectedExchange.processors}
|
||||
totalMs={selectedExchange.durationMs}
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<TopBar
|
||||
breadcrumb={
|
||||
@@ -424,11 +324,12 @@ export function Dashboard() {
|
||||
selectedId={selectedId}
|
||||
sortable
|
||||
flush
|
||||
fillHeight
|
||||
rowAccent={handleRowAccent}
|
||||
expandedContent={(row) =>
|
||||
row.errorMessage ? (
|
||||
<div className={styles.inlineError}>
|
||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
||||
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||
<div>
|
||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
||||
@@ -442,6 +343,101 @@ export function Dashboard() {
|
||||
|
||||
{/* Shortcuts bar */}
|
||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||
</AppShell>
|
||||
|
||||
{/* Detail panel (portals itself) */}
|
||||
{selectedExchange && (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={`${selectedExchange.orderId} — ${selectedExchange.route}`}
|
||||
>
|
||||
{/* Link to full detail page */}
|
||||
<div className={styles.panelSection}>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
||||
>
|
||||
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Overview</div>
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(selectedExchange.status)} />
|
||||
<span>{statusLabel(selectedExchange.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Duration</span>
|
||||
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Route</span>
|
||||
<span>{selectedExchange.route}</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Customer</span>
|
||||
<MonoText size="sm">{selectedExchange.customer}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Agent</span>
|
||||
<MonoText size="sm">{selectedExchange.agent}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Correlation</span>
|
||||
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Timestamp</span>
|
||||
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{totalErrors > 0 && (
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>
|
||||
Errors
|
||||
{totalErrors > 1 && (
|
||||
<Badge label={`+${totalErrors - 1} more`} color="error" variant="outlined" />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.errorBlock}>
|
||||
<div className={styles.errorClass}>
|
||||
{selectedExchange.errorClass ?? processorErrors[0]?.name}
|
||||
</div>
|
||||
<div className={styles.errorMessage}>
|
||||
{selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Flow */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||
<RouteFlow nodes={routeNodes} />
|
||||
</div>
|
||||
|
||||
{/* Processor Timeline */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>
|
||||
Processor Timeline
|
||||
<span className={styles.panelSectionMeta}>{formatDuration(selectedExchange.durationMs)}</span>
|
||||
</div>
|
||||
<ProcessorTimeline
|
||||
processors={selectedExchange.processors}
|
||||
totalMs={selectedExchange.durationMs}
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import styles from './ExchangeDetail.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -22,7 +20,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
|
||||
|
||||
// Mock data
|
||||
import { exchanges } from '../../mocks/exchanges'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
|
||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||
|
||||
@@ -196,11 +194,7 @@ export function ExchangeDetail() {
|
||||
// Not found state
|
||||
if (!exchange) {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
@@ -213,7 +207,7 @@ export function ExchangeDetail() {
|
||||
<div className={styles.content}>
|
||||
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -229,11 +223,7 @@ export function ExchangeDetail() {
|
||||
const isSelectedFailed = selectedProc?.status === 'fail'
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
@@ -454,6 +444,6 @@ export function ExchangeDetail() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import styles from './Inventory.module.css'
|
||||
import { PrimitivesSection } from './sections/PrimitivesSection'
|
||||
import { CompositesSection } from './sections/CompositesSection'
|
||||
import { LayoutSection } from './sections/LayoutSection'
|
||||
import { BrandAssetsSection } from './sections/BrandAssetsSection'
|
||||
|
||||
const NAV_SECTIONS = [
|
||||
{
|
||||
@@ -93,6 +94,13 @@ const NAV_SECTIONS = [
|
||||
{ label: 'TopBar', href: '#topbar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Brand Assets',
|
||||
href: '#brand-assets',
|
||||
components: [
|
||||
{ label: 'Cameleer3 Logo', href: '#brand-assets' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Inventory() {
|
||||
@@ -121,6 +129,7 @@ export function Inventory() {
|
||||
<PrimitivesSection />
|
||||
<CompositesSection />
|
||||
<LayoutSection />
|
||||
<BrandAssetsSection />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
86
src/pages/Inventory/sections/BrandAssetsSection.module.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.componentCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.componentTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.componentDesc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.demoArea {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.logoGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.logoItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.logoPreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-body);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.logoPreview img {
|
||||
display: block;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.logoLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logoExport {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-body);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
69
src/pages/Inventory/sections/BrandAssetsSection.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import styles from './BrandAssetsSection.module.css'
|
||||
import camelLogoSvg from '../../../assets/camel-logo.svg'
|
||||
import cameleer3Logo from '../../../assets/cameleer3-logo.png'
|
||||
|
||||
const LOGO_SIZES = [16, 32, 48, 180, 192, 512] as const
|
||||
|
||||
export function BrandAssetsSection() {
|
||||
return (
|
||||
<section id="brand-assets" className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Brand Assets</h2>
|
||||
|
||||
<div className={styles.componentCard}>
|
||||
<h3 className={styles.componentTitle}>Cameleer3 Logo (PNG)</h3>
|
||||
<p className={styles.componentDesc}>
|
||||
Full-resolution logo and pre-generated size variants for favicons, PWA icons, and social images.
|
||||
Shipped as static assets via <code>@cameleer/design-system/assets/*</code> export.
|
||||
</p>
|
||||
<div className={styles.demoArea}>
|
||||
<div className={styles.logoGrid}>
|
||||
{LOGO_SIZES.map((size) => (
|
||||
<div key={size} className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img
|
||||
src={cameleer3Logo}
|
||||
alt={`Logo ${size}×${size}`}
|
||||
width={Math.min(size, 96)}
|
||||
height={Math.min(size, 96)}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.logoLabel}>{size}×{size}</span>
|
||||
<code className={styles.logoExport}>assets/cameleer3-{size}.png</code>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img
|
||||
src={cameleer3Logo}
|
||||
alt="Full resolution logo"
|
||||
width={96}
|
||||
height={96}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.logoLabel}>Original</span>
|
||||
<code className={styles.logoExport}>assets/cameleer3-logo.png</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.componentCard}>
|
||||
<h3 className={styles.componentTitle}>Camel Logo (SVG)</h3>
|
||||
<p className={styles.componentDesc}>
|
||||
Vector logo for scalable usage. Shipped as a static asset via package export.
|
||||
</p>
|
||||
<div className={styles.demoArea}>
|
||||
<div className={styles.logoGrid}>
|
||||
<div className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img src={camelLogoSvg} alt="Camel SVG logo" width={96} height={96} />
|
||||
</div>
|
||||
<span className={styles.logoLabel}>SVG</span>
|
||||
<code className={styles.logoExport}>assets/camel-logo.svg</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Hexagon, ArrowRight, Diamond, Eye, Pencil, RotateCcw, Trash2, ChevronDown } from 'lucide-react'
|
||||
import styles from './CompositesSection.module.css'
|
||||
import {
|
||||
Accordion,
|
||||
@@ -134,13 +135,27 @@ interface TableRow {
|
||||
exchanges: number
|
||||
}
|
||||
|
||||
const TABLE_DATA: TableRow[] = [
|
||||
{ id: '1', name: 'order-ingest', method: 'POST', status: 'live', exchanges: 1243 },
|
||||
{ id: '2', name: 'payment-validate', method: 'POST', status: 'live', exchanges: 987 },
|
||||
{ id: '3', name: 'inventory-check', method: 'GET', status: 'stale', exchanges: 432 },
|
||||
{ id: '4', name: 'notify-customer', method: 'POST', status: 'live', exchanges: 876 },
|
||||
{ id: '5', name: 'archive-order', method: 'PUT', status: 'dead', exchanges: 54 },
|
||||
const ROUTE_PREFIXES = [
|
||||
'order', 'payment', 'inventory', 'customer', 'shipment', 'user', 'cart',
|
||||
'refund', 'stock', 'email', 'webhook', 'cache', 'log', 'rate', 'health',
|
||||
'session', 'config', 'metrics', 'batch', 'audit', 'invoice', 'product',
|
||||
'catalog', 'search', 'report', 'export', 'import', 'sync', 'alert', 'ticket',
|
||||
]
|
||||
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 minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
|
||||
@@ -157,25 +172,25 @@ const TREE_NODES = [
|
||||
{
|
||||
id: 'app1',
|
||||
label: 'cameleer-prod',
|
||||
icon: '⬡',
|
||||
icon: <Hexagon size={14} />,
|
||||
children: [
|
||||
{
|
||||
id: 'route1',
|
||||
label: 'order-ingest',
|
||||
icon: '→',
|
||||
icon: <ArrowRight size={14} />,
|
||||
children: [
|
||||
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' },
|
||||
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' },
|
||||
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' },
|
||||
{ id: 'proc1', label: 'ValidateOrder', icon: <Diamond size={12} />, meta: '12ms' },
|
||||
{ id: 'proc2', label: 'EnrichPayload', icon: <Diamond size={12} />, meta: '8ms' },
|
||||
{ id: 'proc3', label: 'RouteToQueue', icon: <Diamond size={12} />, meta: '3ms' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'route2',
|
||||
label: 'payment-validate',
|
||||
icon: '→',
|
||||
icon: <ArrowRight size={14} />,
|
||||
children: [
|
||||
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' },
|
||||
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' },
|
||||
{ id: 'proc4', label: 'TokenizeCard', icon: <Diamond size={12} />, meta: '22ms' },
|
||||
{ id: 'proc5', label: 'AuthorizePayment', icon: <Diamond size={12} />, meta: '45ms' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -467,12 +482,13 @@ export function CompositesSection() {
|
||||
title="DataTable"
|
||||
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
|
||||
columns={tableColumns}
|
||||
data={TABLE_DATA}
|
||||
sortable
|
||||
pageSize={5}
|
||||
pageSize={25}
|
||||
fillHeight
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
@@ -505,13 +521,13 @@ export function CompositesSection() {
|
||||
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
||||
>
|
||||
<Dropdown
|
||||
trigger={<Button size="sm" variant="secondary">Actions ▾</Button>}
|
||||
trigger={<Button size="sm" variant="secondary">Actions <ChevronDown size={12} /></Button>}
|
||||
items={[
|
||||
{ label: 'View details', icon: '👁', onClick: () => undefined },
|
||||
{ label: 'Edit route', icon: '✏', onClick: () => undefined },
|
||||
{ label: 'View details', icon: <Eye size={14} />, onClick: () => undefined },
|
||||
{ label: 'Edit route', icon: <Pencil size={14} />, onClick: () => undefined },
|
||||
{ divider: true, label: '' },
|
||||
{ label: 'Restart', icon: '↺', onClick: () => undefined },
|
||||
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true },
|
||||
{ label: 'Restart', icon: <RotateCcw size={14} />, onClick: () => undefined },
|
||||
{ label: 'Delete', icon: <Trash2 size={14} />, onClick: () => undefined, disabled: true },
|
||||
]}
|
||||
/>
|
||||
</DemoCard>
|
||||
@@ -769,7 +785,7 @@ export function CompositesSection() {
|
||||
<DemoCard
|
||||
id="processortimeline"
|
||||
title="ProcessorTimeline"
|
||||
description="Horizontal Gantt-style timeline showing processor execution order, duration, and status."
|
||||
description="Horizontal Gantt-style timeline with selectable rows and optional action menus via actions or getActions prop."
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<ProcessorTimeline
|
||||
@@ -780,6 +796,11 @@ export function CompositesSection() {
|
||||
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
||||
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
||||
]}
|
||||
getActions={(proc) => [
|
||||
{ label: 'Change Log Level', onClick: () => {} },
|
||||
{ label: 'View Configuration', onClick: () => {} },
|
||||
...(proc.status === 'fail' ? [{ label: 'View Stack Trace', onClick: () => {} }] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
@@ -788,7 +809,7 @@ export function CompositesSection() {
|
||||
<DemoCard
|
||||
id="routeflow"
|
||||
title="RouteFlow"
|
||||
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
|
||||
description="Vertical processor node diagram with status coloring, connectors, and optional action menus."
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||
<RouteFlow
|
||||
@@ -802,6 +823,41 @@ export function CompositesSection() {
|
||||
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
||||
]}
|
||||
actions={[
|
||||
{ label: 'Change Log Level', onClick: () => {} },
|
||||
{ label: 'Enable Tracing', onClick: () => {} },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
|
||||
{/* 17c. RouteFlow (Multi-Flow) */}
|
||||
<DemoCard
|
||||
id="routeflow-multi"
|
||||
title="RouteFlow (Multi-Flow)"
|
||||
description="Multiple flow segments with labels, showing a main route alongside an exception handler."
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||
<RouteFlow
|
||||
flows={[
|
||||
{
|
||||
label: 'Main Route',
|
||||
nodes: [
|
||||
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
|
||||
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
|
||||
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow' },
|
||||
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'onException(IOException)',
|
||||
variant: 'error',
|
||||
nodes: [
|
||||
{ name: 'log:error-logger', type: 'process', durationMs: 2, status: 'ok' },
|
||||
{ name: 'dead-letter:failed-orders', type: 'to', durationMs: 14, status: 'fail' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import styles from './LayoutSection.module.css'
|
||||
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
|
||||
import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar'
|
||||
import { SidebarTree } from '../../../design-system/layout/Sidebar/SidebarTree'
|
||||
import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/SidebarTree'
|
||||
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
|
||||
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
|
||||
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
||||
@@ -22,42 +25,42 @@ function DemoCard({ id, title, description, children }: DemoCardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sample data (hierarchical) ───────────────────────────────────────────────
|
||||
// ── Sample tree nodes ────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_APPS: SidebarApp[] = [
|
||||
const SAMPLE_APP_NODES: SidebarTreeNode[] = [
|
||||
{
|
||||
id: 'app1',
|
||||
name: 'cameleer-prod',
|
||||
health: 'live' as const,
|
||||
exchangeCount: 14320,
|
||||
routes: [
|
||||
{ id: 'r1', name: 'order-ingest', exchangeCount: 5421 },
|
||||
{ id: 'r2', name: 'payment-validate', exchangeCount: 3102 },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'ag1', name: 'agent-prod-1', status: 'live' as const, tps: 42 },
|
||||
{ id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: 38 },
|
||||
label: 'cameleer-prod',
|
||||
icon: <StatusDot variant="live" />,
|
||||
badge: '14.3k',
|
||||
path: '/apps/app1',
|
||||
starrable: true,
|
||||
starKey: 'app:app1',
|
||||
children: [
|
||||
{ id: 'app1/r1', label: 'order-ingest', icon: <ChevronRight size={12} />, badge: '5,421', path: '/apps/app1/r1' },
|
||||
{ id: 'app1/r2', label: 'payment-validate', icon: <ChevronRight size={12} />, badge: '3,102', path: '/apps/app1/r2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app2',
|
||||
name: 'cameleer-staging',
|
||||
health: 'stale' as const,
|
||||
exchangeCount: 871,
|
||||
routes: [
|
||||
{ id: 'r3', name: 'notify-customer', exchangeCount: 2201 },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: 5 },
|
||||
label: 'cameleer-staging',
|
||||
icon: <StatusDot variant="stale" />,
|
||||
badge: '871',
|
||||
path: '/apps/app2',
|
||||
starrable: true,
|
||||
starKey: 'app:app2',
|
||||
children: [
|
||||
{ id: 'app2/r3', label: 'notify-customer', icon: <ChevronRight size={12} />, badge: '2,201', path: '/apps/app2/r3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app3',
|
||||
name: 'cameleer-dev',
|
||||
health: 'dead' as const,
|
||||
exchangeCount: 42,
|
||||
routes: [],
|
||||
agents: [],
|
||||
label: 'cameleer-dev',
|
||||
icon: <StatusDot variant="dead" />,
|
||||
badge: '42',
|
||||
path: '/apps/app3',
|
||||
starrable: true,
|
||||
starKey: 'app:app3',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -99,10 +102,19 @@ export function LayoutSection() {
|
||||
<DemoCard
|
||||
id="sidebar"
|
||||
title="Sidebar"
|
||||
description="Navigation sidebar with hierarchical app/route/agent trees, starring, search filter, and bottom links."
|
||||
description="Composable navigation sidebar with sections, tree navigation, and icon-rail collapse mode."
|
||||
>
|
||||
<div className={styles.sidebarPreview}>
|
||||
<Sidebar apps={SAMPLE_APPS} />
|
||||
<Sidebar>
|
||||
<Sidebar.Header logo={<span style={{ fontSize: 20 }}>🐪</span>} title="cameleer" version="v3.2.1" />
|
||||
<Sidebar.Section label="Applications" icon={<Box size={14} />} open={true} onToggle={() => {}} active={false}>
|
||||
<SidebarTree nodes={SAMPLE_APP_NODES} isStarred={() => false} onToggleStar={() => {}} />
|
||||
</Sidebar.Section>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<Settings size={14} />} label="Admin" />
|
||||
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</DemoCard>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import styles from './PrimitivesSection.module.css'
|
||||
import {
|
||||
Alert,
|
||||
@@ -358,7 +359,7 @@ export function PrimitivesSection() {
|
||||
description="Text input with optional leading icon and placeholder."
|
||||
>
|
||||
<Input placeholder="Plain input" />
|
||||
<Input icon="🔍" placeholder="With icon" />
|
||||
<Input icon={<Search size={14} />} placeholder="With icon" />
|
||||
</DemoCard>
|
||||
|
||||
{/* 15b. InlineEdit */}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import styles from './RouteDetail.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -21,7 +20,6 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
|
||||
// Mock data
|
||||
import { routes } from '../../mocks/routes'
|
||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -198,11 +196,7 @@ export function RouteDetail() {
|
||||
// Not found state
|
||||
if (!route) {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
@@ -210,24 +204,19 @@ export function RouteDetail() {
|
||||
{ label: id ?? 'Unknown' },
|
||||
]}
|
||||
environment="PRODUCTION"
|
||||
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<InfoCallout variant="warning">Route "{id}" not found in mock data.</InfoCallout>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const statusVariant = routeStatusVariant(route.status)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
@@ -337,7 +326,7 @@ export function RouteDetail() {
|
||||
expandedContent={(row) =>
|
||||
row.errorMessage ? (
|
||||
<div className={styles.inlineError}>
|
||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
||||
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||
<div>
|
||||
<div className={styles.errorClass}>{row.errorClass}</div>
|
||||
<div className={styles.errorText}>{row.errorMessage}</div>
|
||||
@@ -374,6 +363,6 @@ export function RouteDetail() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
import styles from './Routes.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -33,7 +31,7 @@ import {
|
||||
type RouteMetricRow,
|
||||
} from '../../mocks/metrics'
|
||||
import { routes } from '../../mocks/routes'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
|
||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||
|
||||
@@ -410,7 +408,7 @@ export function Routes() {
|
||||
// ── Route detail view ───────────────────────────────────────────────────────
|
||||
if (routeId && appId && routeDef) {
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
environment="PRODUCTION"
|
||||
@@ -448,13 +446,13 @@ export function Routes() {
|
||||
<RouteFlow nodes={routeFlowNodes} />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Top level / Application level view ──────────────────────────────────────
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
environment="PRODUCTION"
|
||||
@@ -533,6 +531,6 @@ export function Routes() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||