Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -15,7 +15,21 @@
|
|||||||
"Bash(echo \"EXIT:$?\")",
|
"Bash(echo \"EXIT:$?\")",
|
||||||
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||||
"Bash(echo \"EXIT_CODE=$?\")",
|
"Bash(echo \"EXIT_CODE=$?\")",
|
||||||
"Bash(echo \"EXIT=$?\")"
|
"Bash(echo \"EXIT=$?\")",
|
||||||
|
"mcp__gitea__actions_config_read",
|
||||||
|
"mcp__gitea__search_repos",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")",
|
||||||
|
"Bash(npx vite:*)",
|
||||||
|
"Bash(cd:*)",
|
||||||
|
"mcp__gitea__actions_run_read",
|
||||||
|
"mcp__gitea__get_file_contents",
|
||||||
|
"WebFetch(domain:ui.shadcn.com)",
|
||||||
|
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||||
|
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(xargs cat:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
refs/tags/v*)
|
refs/tags/v*)
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
npm version "$VERSION" --no-git-tag-version
|
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
62
.gitea/workflows/sonarqube.yml
Normal file
62
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: SonarQube Analysis
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sonarqube:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: npx vitest run --exclude 'e2e/**' --coverage --coverage.reporter=lcov
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: Install sonar-scanner
|
||||||
|
run: |
|
||||||
|
SONAR_SCANNER_VERSION=6.2.1.4610
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
PLATFORM="linux-aarch64"
|
||||||
|
else
|
||||||
|
PLATFORM="linux-x64"
|
||||||
|
fi
|
||||||
|
curl -sSLo sonar-scanner.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-${PLATFORM}.zip"
|
||||||
|
unzip -q sonar-scanner.zip
|
||||||
|
echo "$PWD/sonar-scanner-${SONAR_SCANNER_VERSION}-${PLATFORM}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run SonarQube analysis
|
||||||
|
env:
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$SONAR_HOST_URL" ] || ! echo "$SONAR_HOST_URL" | grep -qE '^https?://'; then
|
||||||
|
echo "::error::SONAR_HOST_URL is missing or invalid (got: '$SONAR_HOST_URL'). Set it as a repo variable with full URL (e.g. https://sonar.example.com)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sonar-scanner \
|
||||||
|
-Dsonar.host.url="$SONAR_HOST_URL" \
|
||||||
|
-Dsonar.login="$SONAR_TOKEN" \
|
||||||
|
-Dsonar.projectKey=cameleer-design-system \
|
||||||
|
-Dsonar.projectName="Cameleer Design System" \
|
||||||
|
-Dsonar.sources=src/design-system \
|
||||||
|
-Dsonar.tests=src/design-system \
|
||||||
|
-Dsonar.test.inclusions="**/*.test.tsx,**/*.test.ts" \
|
||||||
|
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info \
|
||||||
|
-Dsonar.exclusions="**/node_modules/**,**/dist/**"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
12
CLAUDE.md
12
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 { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
|
||||||
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
|
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
|
||||||
import { AppShell } from '../design-system/layout/AppShell'
|
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'
|
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -93,6 +97,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
|||||||
// All components from single entry
|
// All components from single entry
|
||||||
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
|
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
|
// Types
|
||||||
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
||||||
|
|
||||||
@@ -104,6 +112,10 @@ import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
|
|||||||
// Utils
|
// Utils
|
||||||
import { hashColor } from '@cameleer/design-system'
|
import { hashColor } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Recharts theme (for advanced charts — treemap, radar, heatmap, etc.)
|
||||||
|
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
import type { ChartSeries, DataPoint } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Styles (once, at app root)
|
// Styles (once, at app root)
|
||||||
import '@cameleer/design-system/style.css'
|
import '@cameleer/design-system/style.css'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -38,10 +38,12 @@
|
|||||||
- Removable label → **Tag**
|
- Removable label → **Tag**
|
||||||
|
|
||||||
### "I need navigation"
|
### "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**
|
- Breadcrumb trail → **Breadcrumb**
|
||||||
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
|
- 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"
|
### "I need floating content"
|
||||||
- Tooltip on hover → **Tooltip**
|
- Tooltip on hover → **Tooltip**
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
- Time series → **LineChart**, **AreaChart**
|
- Time series → **LineChart**, **AreaChart**
|
||||||
- Categorical comparison → **BarChart**
|
- Categorical comparison → **BarChart**
|
||||||
- Inline trend → **Sparkline**
|
- Inline trend → **Sparkline**
|
||||||
|
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
|
||||||
- Event log → **EventFeed**
|
- Event log → **EventFeed**
|
||||||
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||||
- Processing pipeline (flow diagram) → **RouteFlow**
|
- Processing pipeline (flow diagram) → **RouteFlow**
|
||||||
@@ -98,7 +101,24 @@
|
|||||||
|
|
||||||
### Standard page layout
|
### 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
|
### Data page pattern
|
||||||
@@ -160,6 +180,53 @@ StatCard strip (top, recalculates per scope)
|
|||||||
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
|
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Charting Strategy
|
||||||
|
|
||||||
|
The design system includes built-in **AreaChart**, **BarChart**, **LineChart**, and **Sparkline** components for standard use cases. For advanced chart types (treemap, radar, heatmap, pie, sankey, etc.), consuming apps should use **Recharts** directly with the design system's theme preset for visual consistency.
|
||||||
|
|
||||||
|
**Recharts is the app's dependency, not the design system's.** The design system only exports a theme config object.
|
||||||
|
|
||||||
|
### Setup in consuming app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install recharts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage with theme preset
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, LineChart, Line,
|
||||||
|
CartesianGrid, XAxis, YAxis, Tooltip, Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
<XAxis dataKey="name" {...rechartsTheme.xAxis} />
|
||||||
|
<YAxis {...rechartsTheme.yAxis} />
|
||||||
|
<Tooltip {...rechartsTheme.tooltip} />
|
||||||
|
<Legend {...rechartsTheme.legend} />
|
||||||
|
<Line dataKey="value" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
|
||||||
|
<Line dataKey="other" stroke={CHART_COLORS[1]} strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exports
|
||||||
|
|
||||||
|
| Export | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `rechartsTheme.cartesianGrid` | Dashed gridlines, subtle stroke |
|
||||||
|
| `rechartsTheme.xAxis` | Mono font axis ticks, subtle color |
|
||||||
|
| `rechartsTheme.yAxis` | Mono font axis ticks, no axis line |
|
||||||
|
| `rechartsTheme.tooltip` | Surface bg, border, shadow, monospace values |
|
||||||
|
| `rechartsTheme.legend` | Matching text size and color |
|
||||||
|
| `rechartsTheme.colors` | The 8 `CHART_COLORS` (CSS variables with light/dark support) |
|
||||||
|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
||||||
|
| `ChartSeries` / `DataPoint` | Type interfaces for chart data |
|
||||||
|
|
||||||
## Component Index
|
## Component Index
|
||||||
|
|
||||||
| Component | Layer | When to use |
|
| Component | Layer | When to use |
|
||||||
@@ -179,7 +246,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| Checkbox | primitive | Boolean input with label |
|
| Checkbox | primitive | Boolean input with label |
|
||||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||||
| Collapsible | primitive | Single expand/collapse section |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||||
| Pagination | primitive | Page navigation controls |
|
| Pagination | primitive | Page navigation controls |
|
||||||
| Popover | composite | Click-triggered floating panel with arrow |
|
| Popover | composite | Click-triggered floating panel with arrow |
|
||||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. |
|
||||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. |
|
||||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||||
@@ -236,7 +303,9 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
| 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) |
|
| 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 badge, user avatar |
|
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
||||||
|
|
||||||
## Import Paths
|
## Import Paths
|
||||||
@@ -248,6 +317,10 @@ import { Button, Input, Badge } from './design-system/primitives'
|
|||||||
import { DataTable, Modal, Toast } from './design-system/composites'
|
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||||
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||||
import { AppShell } from './design-system/layout/AppShell'
|
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'
|
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
1609
docs/superpowers/plans/2026-04-02-composable-sidebar.md
Normal file
1609
docs/superpowers/plans/2026-04-02-composable-sidebar.md
Normal file
File diff suppressed because it is too large
Load Diff
399
docs/superpowers/specs/2026-04-02-composable-sidebar-design.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
165
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Admin - User Management (/admin/rbac)', () => {
|
||||||
|
test('renders admin tabs and user table', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Admin navigation tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible()
|
||||||
|
|
||||||
|
// User Management sub-tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar breadcrumb
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Users, Groups, and Roles tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Default tab is Users
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
|
||||||
|
// Switch to Groups tab
|
||||||
|
await page.getByRole('tab', { name: 'Groups' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch to Roles tab
|
||||||
|
await page.getByRole('tab', { name: 'Roles' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch back to Users
|
||||||
|
await page.getByRole('tab', { name: 'Users' }).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating between admin sections via tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Click Audit Log tab
|
||||||
|
await page.getByRole('tab', { name: 'Audit Log' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/audit/)
|
||||||
|
|
||||||
|
// Click OIDC tab
|
||||||
|
await page.getByRole('tab', { name: 'OIDC' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/oidc/)
|
||||||
|
|
||||||
|
// Back to User Management
|
||||||
|
await page.getByRole('tab', { name: 'User Management' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/rbac/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - Audit Log (/admin/audit)', () => {
|
||||||
|
test('renders audit table with filters', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Filter inputs exist
|
||||||
|
await expect(page.getByPlaceholder('Filter by user...')).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('Search action or target...')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filtering audit events by search', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
const searchInput = page.getByPlaceholder('Search action or target...')
|
||||||
|
await searchInput.fill('deploy')
|
||||||
|
|
||||||
|
// Table should update
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
const count = await rows.count()
|
||||||
|
expect(count).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - OIDC Config (/admin/oidc)', () => {
|
||||||
|
test('renders OIDC form with all fields', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Section headers
|
||||||
|
await expect(page.getByText('Behavior')).toBeVisible()
|
||||||
|
await expect(page.getByText('Provider Settings')).toBeVisible()
|
||||||
|
await expect(page.getByText('Claim Mapping')).toBeVisible()
|
||||||
|
await expect(page.getByText('Default Roles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Danger Zone')).toBeVisible()
|
||||||
|
|
||||||
|
// Form fields by id
|
||||||
|
await expect(page.locator('#issuer')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-id')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-secret')).toBeVisible()
|
||||||
|
await expect(page.locator('#roles-claim')).toBeVisible()
|
||||||
|
await expect(page.locator('#name-claim')).toBeVisible()
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible()
|
||||||
|
|
||||||
|
// Default roles tags
|
||||||
|
await expect(page.getByText('USER').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('VIEWER').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggling Enabled switch', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// The Toggle's checkbox is visually hidden — click the label wrapper instead
|
||||||
|
const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' })
|
||||||
|
await enabledLabel.click()
|
||||||
|
|
||||||
|
// Should not crash; label still visible
|
||||||
|
await expect(enabledLabel).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adding and removing a role tag', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Add a new role
|
||||||
|
const roleInput = page.getByPlaceholder('Add role...')
|
||||||
|
await roleInput.fill('EDITOR')
|
||||||
|
// Use the Add button next to the input (scoped to same row)
|
||||||
|
await roleInput.press('Enter')
|
||||||
|
|
||||||
|
// New role tag should appear
|
||||||
|
await expect(page.getByText('EDITOR')).toBeVisible()
|
||||||
|
|
||||||
|
// Remove it via aria-label on the tag's remove button
|
||||||
|
await page.getByRole('button', { name: 'Remove EDITOR' }).click()
|
||||||
|
|
||||||
|
// EDITOR tag should be gone
|
||||||
|
await expect(page.getByText('EDITOR')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Save button shows success toast', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
await expect(page.getByText('Settings saved')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Delete button shows confirmation dialog', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Delete OIDC/i }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog should appear
|
||||||
|
await expect(page.getByText('Delete OIDC configuration?')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
80
e2e/agents.spec.ts
Normal file
80
e2e/agents.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Agent Health (/agents)', () => {
|
||||||
|
test('renders stat cards and group cards', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Stat strip
|
||||||
|
await expect(page.getByText('Total Agents')).toBeVisible()
|
||||||
|
await expect(page.getByText('Total TPS')).toBeVisible()
|
||||||
|
|
||||||
|
// Group cards for each application
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('notification-hub').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Instance tables have data
|
||||||
|
const instanceRows = page.locator('table tbody tr')
|
||||||
|
expect(await instanceRows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Instance table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline section
|
||||||
|
await expect(page.getByText('Timeline').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking an instance row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click first instance row
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Detail panel opens — look for detail-specific labels
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Throughput').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detail panel has Performance tab with charts', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click an instance to open detail panel
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Wait for panel to open
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
|
||||||
|
// DetailPanel tabs are plain buttons (not role="tab")
|
||||||
|
// Switch to Performance tab
|
||||||
|
const perfTab = page.getByRole('button', { name: 'Performance' })
|
||||||
|
await perfTab.click()
|
||||||
|
|
||||||
|
// Performance charts should render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped agents view', async ({ page }) => {
|
||||||
|
await page.goto('/agents/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb/scope shows app
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible()
|
||||||
|
|
||||||
|
// Only order-service agents should show
|
||||||
|
await expect(page.getByText('ord-1').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-2').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-3').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dead agent shows alert banner', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// notification-hub has a dead instance, should show alert
|
||||||
|
await expect(page.getByText('Single point of failure')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
90
e2e/dashboard.spec.ts
Normal file
90
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */
|
||||||
|
async function widenTimeRange(page: import('@playwright/test').Page) {
|
||||||
|
await page.getByRole('tab', { name: '7d' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Dashboard (/apps)', () => {
|
||||||
|
test('renders KPI stat cards and exchange table', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// KPI health strip renders
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data rows
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Sidebar renders with app names
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar renders
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible()
|
||||||
|
await expect(page.getByText('PRODUCTION')).toBeVisible()
|
||||||
|
|
||||||
|
// Shortcuts bar
|
||||||
|
await expect(page.getByText('Ctrl+K').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a table row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Click the first data row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Detail panel should open — look for "Open full details" link
|
||||||
|
await expect(page.getByText('Open full details')).toBeVisible()
|
||||||
|
// Overview section
|
||||||
|
await expect(page.getByText('Correlation').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => {
|
||||||
|
await page.goto('/apps/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows app scope
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table should still render
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sidebar navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
|
||||||
|
// Click on an app in the sidebar
|
||||||
|
const sidebarApp = page.getByText('order-service').first()
|
||||||
|
await sidebarApp.click()
|
||||||
|
|
||||||
|
// URL should change to the app scope
|
||||||
|
await expect(page).toHaveURL(/\/apps\/order-service/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inspect button navigates to exchange detail', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Wait for table rows to appear
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
|
||||||
|
// Click the inspect button (↗) on first row
|
||||||
|
const inspectBtn = firstRow.locator('button[title="Inspect exchange"]')
|
||||||
|
await inspectBtn.click()
|
||||||
|
|
||||||
|
// Should navigate to exchange detail page
|
||||||
|
await expect(page).toHaveURL(/\/exchanges\//)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
e2e/exchanges.spec.ts
Normal file
60
e2e/exchanges.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Exchange Detail (/exchanges/:id)', () => {
|
||||||
|
test('renders exchange header, timeline, and message panels', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Exchange header — use the one NOT in the breadcrumb
|
||||||
|
await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible()
|
||||||
|
|
||||||
|
// Header stats
|
||||||
|
await expect(page.getByText('Duration').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Processors').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Processor Timeline section
|
||||||
|
await expect(page.getByText('Processor Timeline').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline/Flow toggle buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible()
|
||||||
|
|
||||||
|
// Message IN panel
|
||||||
|
await expect(page.getByText('Message IN')).toBeVisible()
|
||||||
|
await expect(page.getByText('Headers').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Body').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Timeline and Flow view', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Default view is timeline (gantt)
|
||||||
|
const timelineBtn = page.getByRole('button', { name: 'Timeline' })
|
||||||
|
const flowBtn = page.getByRole('button', { name: 'Flow' })
|
||||||
|
|
||||||
|
// Switch to Flow view
|
||||||
|
await flowBtn.click()
|
||||||
|
|
||||||
|
// Flow view should render (RouteFlow component)
|
||||||
|
await expect(flowBtn).toHaveClass(/active|Active/)
|
||||||
|
|
||||||
|
// Switch back to Timeline
|
||||||
|
await timelineBtn.click()
|
||||||
|
await expect(timelineBtn).toHaveClass(/active|Active/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('not-found exchange shows warning', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/nonexistent-id')
|
||||||
|
|
||||||
|
await expect(page.getByText('not found', { exact: false })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('breadcrumb navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Click Applications breadcrumb to go back
|
||||||
|
const appsBreadcrumb = page.getByRole('link', { name: 'Applications' })
|
||||||
|
await appsBreadcrumb.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/apps/)
|
||||||
|
})
|
||||||
|
})
|
||||||
63
e2e/routes.spec.ts
Normal file
63
e2e/routes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Routes (/routes)', () => {
|
||||||
|
test('renders KPI cards, route table, and charts', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// KPI cards
|
||||||
|
await expect(page.getByText('Total Throughput')).toBeVisible()
|
||||||
|
await expect(page.getByText('System Error Rate')).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency Percentiles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Active Routes')).toBeVisible()
|
||||||
|
await expect(page.getByText('In-Flight Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Route performance table
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible()
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Charts render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency (ms)')).toBeVisible()
|
||||||
|
await expect(page.getByText('Errors by Route')).toBeVisible()
|
||||||
|
await expect(page.getByText('Message Volume (msg/min)')).toBeVisible()
|
||||||
|
|
||||||
|
// Auto-refresh indicator
|
||||||
|
await expect(page.getByText('Auto-refresh: 30s')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a route row navigates to route detail', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// Click first route row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Should navigate to route detail
|
||||||
|
await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/)
|
||||||
|
|
||||||
|
// Route detail view: processor performance table
|
||||||
|
await expect(page.getByText('Processor Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible()
|
||||||
|
|
||||||
|
// Route Flow diagram
|
||||||
|
await expect(page.getByText('Route Flow')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped routes view filters data', async ({ page }) => {
|
||||||
|
await page.goto('/routes/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows scope
|
||||||
|
await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible()
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table still renders
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
741
package-lock.json
generated
741
package-lock.json
generated
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.3",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.3",
|
"version": "0.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"happy-dom": "^20.8.4",
|
"happy-dom": "^20.8.4",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
@@ -39,6 +41,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@ampproject/remapping": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -331,6 +347,16 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -773,6 +799,34 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@isaacs/cliui": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^5.1.2",
|
||||||
|
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||||
|
"strip-ansi": "^7.0.1",
|
||||||
|
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||||
|
"wrap-ansi": "^8.1.0",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@istanbuljs/schema": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -931,6 +985,17 @@
|
|||||||
"resolve": "~1.22.2"
|
"resolve": "~1.22.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgjs/parseargs": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
@@ -1697,6 +1762,40 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ampproject/remapping": "^2.3.0",
|
||||||
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
|
"ast-v8-to-istanbul": "^0.3.3",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-lib-source-maps": "^5.0.6",
|
||||||
|
"istanbul-reports": "^3.1.7",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"magicast": "^0.3.5",
|
||||||
|
"std-env": "^3.9.0",
|
||||||
|
"test-exclude": "^7.0.1",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vitest/browser": "3.2.4",
|
||||||
|
"vitest": "3.2.4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
@@ -1917,9 +2016,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/language-core/node_modules/brace-expansion": {
|
"node_modules/@vue/language-core/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2025,7 +2124,6 @@
|
|||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -2074,6 +2172,25 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul": {
|
||||||
|
"version": "0.3.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
|
||||||
|
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.31",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"js-tokens": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -2098,9 +2215,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2202,6 +2319,26 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/compare-versions": {
|
"node_modules/compare-versions": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
|
||||||
@@ -2236,6 +2373,21 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css.escape": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -2313,6 +2465,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/eastasianwidth": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.313",
|
"version": "1.5.313",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
||||||
@@ -2320,6 +2479,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "9.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -2461,6 +2627,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/foreground-child": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"signal-exit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "11.3.4",
|
"version": "11.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
|
||||||
@@ -2511,6 +2694,61 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -2519,9 +2757,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/happy-dom": {
|
"node_modules/happy-dom": {
|
||||||
"version": "20.8.4",
|
"version": "20.8.9",
|
||||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
|
||||||
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==",
|
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2569,6 +2807,13 @@
|
|||||||
"he": "bin/he"
|
"he": "bin/he"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-lazy": {
|
"node_modules/import-lazy": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
|
||||||
@@ -2605,6 +2850,106 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-source-maps": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.23",
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"istanbul-lib-coverage": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jju": {
|
"node_modules/jju": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
||||||
@@ -2714,6 +3059,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -2735,6 +3089,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.25.4",
|
||||||
|
"@babel/types": "^7.25.4",
|
||||||
|
"source-map-js": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/min-indent": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -2761,6 +3156,16 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mlly": {
|
"node_modules/mlly": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
|
||||||
@@ -2833,6 +3238,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -2840,6 +3252,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
@@ -2847,6 +3269,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
|
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -2872,9 +3318,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3194,6 +3640,29 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -3201,6 +3670,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -3252,6 +3734,103 @@
|
|||||||
"node": ">=0.6.19"
|
"node": ">=0.6.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eastasianwidth": "^0.2.0",
|
||||||
|
"emoji-regex": "^9.2.2",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs": {
|
||||||
|
"name": "string-width",
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs": {
|
||||||
|
"name": "strip-ansi",
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi/node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
@@ -3327,6 +3906,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/test-exclude": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@istanbuljs/schema": "^0.1.2",
|
||||||
|
"glob": "^10.4.1",
|
||||||
|
"minimatch": "^10.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -3672,6 +4266,22 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -3689,6 +4299,107 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.1.0",
|
||||||
|
"string-width": "^5.0.1",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs": {
|
||||||
|
"name": "wrap-ansi",
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.3",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.es.js",
|
"main": "./dist/index.es.js",
|
||||||
"module": "./dist/index.es.js",
|
"module": "./dist/index.es.js",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"happy-dom": "^20.8.4",
|
"happy-dom": "^20.8.4",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
|
|||||||
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData'
|
|||||||
import { exchanges } from './mocks/exchanges'
|
import { exchanges } from './mocks/exchanges'
|
||||||
import { routes } from './mocks/routes'
|
import { routes } from './mocks/routes'
|
||||||
import { agents } from './mocks/agents'
|
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()
|
const routeToApp = buildRouteToAppMap()
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route element={<LayoutShell />}>
|
||||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||||
<Route path="/apps" element={<Dashboard />} />
|
<Route path="/apps" element={<Dashboard />} />
|
||||||
<Route path="/apps/:id" element={<Dashboard />} />
|
<Route path="/apps/:id" element={<Dashboard />} />
|
||||||
@@ -93,6 +95,7 @@ export default function App() {
|
|||||||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||||
<Route path="/admin/rbac" element={<UserManagement />} />
|
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||||
<Route path="/api-docs" element={<ApiDocs />} />
|
<Route path="/api-docs" element={<ApiDocs />} />
|
||||||
|
</Route>
|
||||||
<Route path="/inventory" element={<Inventory />} />
|
<Route path="/inventory" element={<Inventory />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
|
|||||||
@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
|
|||||||
|
|
||||||
it('renders danger variant icon', () => {
|
it('renders danger variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="danger" />)
|
render(<AlertDialog {...defaultProps} variant="danger" />)
|
||||||
// Icon area should be present (aria-hidden)
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders warning variant icon', () => {
|
it('renders warning variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="warning" />)
|
render(<AlertDialog {...defaultProps} variant="warning" />)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders info variant icon', () => {
|
it('renders info variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="info" />)
|
render(<AlertDialog {...defaultProps} variant="info" />)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import { XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||||
import { Modal } from '../Modal/Modal'
|
import { Modal } from '../Modal/Modal'
|
||||||
import { Button } from '../../primitives/Button/Button'
|
import { Button } from '../../primitives/Button/Button'
|
||||||
import styles from './AlertDialog.module.css'
|
import styles from './AlertDialog.module.css'
|
||||||
@@ -16,10 +17,10 @@ interface AlertDialogProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
|
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
|
||||||
danger: '✕',
|
danger: <XCircle size={20} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={20} />,
|
||||||
info: 'ℹ',
|
info: <Info size={20} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertDialog({
|
export function AlertDialog({
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ export function BarChart({
|
|||||||
setTooltip({ x: mx, y: my, label: catLabel, values })
|
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 (
|
return (
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
||||||
@@ -138,19 +152,7 @@ export function BarChart({
|
|||||||
height={barH}
|
height={barH}
|
||||||
fill={color}
|
fill={color}
|
||||||
className={styles.bar}
|
className={styles.bar}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
||||||
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],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -184,19 +186,7 @@ export function BarChart({
|
|||||||
height={barH}
|
height={barH}
|
||||||
fill={color}
|
fill={color}
|
||||||
className={styles.bar}
|
className={styles.bar}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
||||||
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],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -277,6 +277,23 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Match context snippet */
|
||||||
|
.matchContext {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-top: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matchContext em {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Match highlight */
|
/* Match highlight */
|
||||||
.mark {
|
.mark {
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CommandPalette.module.css'
|
import styles from './CommandPalette.module.css'
|
||||||
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
||||||
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
||||||
@@ -12,24 +13,36 @@ interface CommandPaletteProps {
|
|||||||
onSelect: (result: SearchResult) => void
|
onSelect: (result: SearchResult) => void
|
||||||
data: SearchResult[]
|
data: SearchResult[]
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
|
onQueryChange?: (query: string) => void
|
||||||
|
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
|
||||||
|
* Useful for applying the query as a full-text search filter. */
|
||||||
|
onSubmit?: (query: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
|
||||||
all: 'All',
|
|
||||||
application: 'Applications',
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
|
attribute: 'Attributes',
|
||||||
route: 'Routes',
|
route: 'Routes',
|
||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
/** Preferred display order for known categories */
|
||||||
'all',
|
const KNOWN_CATEGORY_ORDER: string[] = [
|
||||||
'application',
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
|
'attribute',
|
||||||
'route',
|
'route',
|
||||||
'agent',
|
'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 {
|
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
||||||
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
||||||
|
|
||||||
@@ -60,12 +73,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
|||||||
return <>{parts}</>
|
return <>{parts}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
|
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const userNavigated = useRef(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -88,25 +102,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
setQuery('')
|
setQuery('')
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
setExpandedId(null)
|
setExpandedId(null)
|
||||||
|
userNavigated.current = false
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Filter results
|
// Stage 1: apply text query + scope filters (used for counts)
|
||||||
const filtered = useMemo(() => {
|
const queryFiltered = useMemo(() => {
|
||||||
let results = data
|
let results = data
|
||||||
|
|
||||||
if (activeCategory !== 'all') {
|
|
||||||
results = results.filter((r) => r.category === activeCategory)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
results = results.filter(
|
results = results.filter(
|
||||||
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply scope filters
|
|
||||||
for (const sf of scopeFilters) {
|
for (const sf of scopeFilters) {
|
||||||
results = results.filter((r) =>
|
results = results.filter((r) =>
|
||||||
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
||||||
@@ -114,11 +124,17 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}, [data, query, activeCategory, scopeFilters])
|
}, [data, query, scopeFilters])
|
||||||
|
|
||||||
|
// Stage 2: apply category filter (used for display)
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (activeCategory === 'all') return queryFiltered
|
||||||
|
return queryFiltered.filter((r) => r.category === activeCategory)
|
||||||
|
}, [queryFiltered, activeCategory])
|
||||||
|
|
||||||
// Group results by category
|
// Group results by category
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<SearchCategory, SearchResult[]>()
|
const map = new Map<string, SearchResult[]>()
|
||||||
for (const r of filtered) {
|
for (const r of filtered) {
|
||||||
if (!map.has(r.category)) map.set(r.category, [])
|
if (!map.has(r.category)) map.set(r.category, [])
|
||||||
map.get(r.category)!.push(r)
|
map.get(r.category)!.push(r)
|
||||||
@@ -129,13 +145,26 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
// Flatten for keyboard nav
|
// Flatten for keyboard nav
|
||||||
const flatResults = useMemo(() => filtered, [filtered])
|
const flatResults = useMemo(() => filtered, [filtered])
|
||||||
|
|
||||||
// Counts per category
|
// Counts per category (from query-filtered, before category filter)
|
||||||
const categoryCounts = useMemo(() => {
|
const categoryCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = { all: data.length }
|
const counts: Record<string, number> = { all: queryFiltered.length }
|
||||||
for (const r of data) {
|
for (const r of queryFiltered) {
|
||||||
counts[r.category] = (counts[r.category] ?? 0) + 1
|
counts[r.category] = (counts[r.category] ?? 0) + 1
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
|
}, [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])
|
}, [data])
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
@@ -145,15 +174,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
break
|
break
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.max(i - 1, 0))
|
setFocusedIdx((i) => Math.max(i - 1, 0))
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (flatResults[focusedIdx]) {
|
if (!userNavigated.current && onSubmit && query.trim()) {
|
||||||
|
onSubmit(query.trim())
|
||||||
|
onClose()
|
||||||
|
} else if (flatResults[focusedIdx]) {
|
||||||
onSelect(flatResults[focusedIdx])
|
onSelect(flatResults[focusedIdx])
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -171,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
|
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
|
if (!open) return null
|
||||||
|
|
||||||
return createPortal(
|
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
|
<div
|
||||||
className={styles.panel}
|
className={styles.panel}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -185,7 +232,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
>
|
>
|
||||||
{/* Search input area */}
|
{/* Search input area */}
|
||||||
<div className={styles.searchArea}>
|
<div className={styles.searchArea}>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">⌕</span>
|
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
|
||||||
{scopeFilters.map((sf, i) => (
|
{scopeFilters.map((sf, i) => (
|
||||||
<span key={i} className={styles.scopeTag}>
|
<span key={i} className={styles.scopeTag}>
|
||||||
<span className={styles.scopeField}>{sf.field}:</span>
|
<span className={styles.scopeField}>{sf.field}:</span>
|
||||||
@@ -195,7 +242,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onClick={() => removeScopeFilter(i)}
|
onClick={() => removeScopeFilter(i)}
|
||||||
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
||||||
>
|
>
|
||||||
×
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -208,6 +255,8 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value)
|
setQuery(e.target.value)
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
|
userNavigated.current = false
|
||||||
|
onQueryChange?.(e.target.value)
|
||||||
}}
|
}}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
/>
|
/>
|
||||||
@@ -216,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
|
|
||||||
{/* Category tabs */}
|
{/* Category tabs */}
|
||||||
<div className={styles.tabs} role="tablist">
|
<div className={styles.tabs} role="tablist">
|
||||||
{ALL_CATEGORIES.map((cat) => (
|
{visibleCategories.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -232,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CATEGORY_LABELS[cat]}
|
{categoryLabel(cat)}
|
||||||
{categoryCounts[cat] != null && (
|
{categoryCounts[cat] != null && (
|
||||||
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
<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]) => (
|
Array.from(grouped.entries()).map(([category, items]) => (
|
||||||
<div key={category} className={styles.group}>
|
<div key={category} className={styles.group}>
|
||||||
<div className={styles.groupHeader}>
|
<div className={styles.groupHeader}>
|
||||||
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
|
<SectionHeader>{categoryLabel(category)}</SectionHeader>
|
||||||
</div>
|
</div>
|
||||||
{items.map((result) => {
|
{items.map((result) => {
|
||||||
const flatIdx = flatResults.indexOf(result)
|
const flatIdx = flatResults.indexOf(result)
|
||||||
@@ -276,7 +325,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
onSelect(result)
|
onSelect(result)
|
||||||
onClose()
|
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}>
|
<div className={styles.itemMain}>
|
||||||
{result.icon && (
|
{result.icon && (
|
||||||
@@ -301,18 +356,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
<div className={styles.itemMeta}>
|
<div className={styles.itemMeta}>
|
||||||
{highlightText(result.meta, query)}
|
{highlightText(result.meta, query)}
|
||||||
</div>
|
</div>
|
||||||
|
{result.matchContext && (
|
||||||
|
<div
|
||||||
|
className={styles.matchContext}
|
||||||
|
dangerouslySetInnerHTML={{ __html: result.matchContext }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{result.expandedContent && (
|
{result.expandedContent && (
|
||||||
<button
|
<button
|
||||||
className={styles.expandBtn}
|
className={styles.expandBtn}
|
||||||
onClick={(e) => {
|
onClick={(e) => toggleExpanded(e, result.id)}
|
||||||
e.stopPropagation()
|
|
||||||
setExpandedId((prev) => (prev === result.id ? null : result.id))
|
|
||||||
}}
|
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-label="Toggle detail"
|
aria-label="Toggle detail"
|
||||||
>
|
>
|
||||||
{isExpanded ? '▲' : '▼'}
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +399,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Enter" />
|
<KeyboardHint keys="Enter" />
|
||||||
<span>Open</span>
|
<span>Search</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Esc" />
|
<KeyboardHint keys="Esc" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
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 {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -13,6 +14,10 @@ export interface SearchResult {
|
|||||||
path?: string
|
path?: string
|
||||||
expandedContent?: string
|
expandedContent?: string
|
||||||
matchRanges?: [number, number][]
|
matchRanges?: [number, number][]
|
||||||
|
/** Skip client-side query filtering (result already matched server-side) */
|
||||||
|
serverFiltered?: boolean
|
||||||
|
/** Server-side match snippet with <em> tags around matched text */
|
||||||
|
matchContext?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScopeFilter {
|
export interface ScopeFilter {
|
||||||
|
|||||||
@@ -12,6 +12,23 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fillHeight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -35,6 +52,9 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: color 0.12s;
|
transition: color 0.12s;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable {
|
.th.sortable {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
rowAccent,
|
rowAccent,
|
||||||
expandedContent,
|
expandedContent,
|
||||||
flush = false,
|
flush = false,
|
||||||
|
fillHeight = false,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
@@ -81,7 +82,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
|
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
|
||||||
<div className={styles.scroll}>
|
<div className={styles.scroll}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface DataTableProps<T extends { id: string }> {
|
|||||||
expandedContent?: (row: T) => ReactNode | null
|
expandedContent?: (row: T) => ReactNode | null
|
||||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||||
flush?: boolean
|
flush?: boolean
|
||||||
|
/** Make the table fill remaining vertical space in a flex parent.
|
||||||
|
* The table body scrolls while the header stays sticky and the
|
||||||
|
* pagination footer stays pinned at the bottom. */
|
||||||
|
fillHeight?: boolean
|
||||||
/** Controlled sort: called when the user clicks a sortable column header.
|
/** Controlled sort: called when the user clicks a sortable column header.
|
||||||
* When provided, the component skips client-side sorting — the caller is
|
* When provided, the component skips client-side sorting — the caller is
|
||||||
* responsible for providing `data` in the desired order. */
|
* 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 {
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100vh;
|
||||||
width: 0;
|
width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: width 0.25s ease, opacity 0.2s ease;
|
transition: width 0.25s ease, opacity 0.2s ease;
|
||||||
@@ -7,13 +24,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
flex-shrink: 0;
|
z-index: 100;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel.open {
|
.panel.open {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-left-color: var(--border);
|
border-left-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
animation: slideInRight 0.25s ease-out both;
|
animation: slideInRight 0.25s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import styles from './DetailPanel.module.css'
|
import styles from './DetailPanel.module.css'
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
@@ -22,7 +23,9 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
|||||||
|
|
||||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
|
<>
|
||||||
|
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
|
||||||
<aside
|
<aside
|
||||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
@@ -64,5 +67,10 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Portal to AppShell level if target exists, otherwise render in place
|
||||||
|
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||||
|
return portalTarget ? createPortal(content, portalTarget) : content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
|
||||||
import styles from './EventFeed.module.css'
|
import styles from './EventFeed.module.css'
|
||||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
|
||||||
error: '\u2715', // ✕
|
error: <XIcon size={14} />,
|
||||||
warning: '\u26A0', // ⚠
|
warning: <AlertTriangle size={14} />,
|
||||||
success: '\u25B6', // ▶
|
success: <Play size={14} />,
|
||||||
running: '\u2699', // ⚙
|
running: <Loader size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
||||||
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
||||||
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to top (newest entries are at top in desc sort)
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPaused) {
|
if (!isPaused) {
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}
|
}
|
||||||
}, [events, isPaused, scrollToBottom])
|
}, [events, isPaused, scrollToTop])
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
|
const atTop = el.scrollTop < 8
|
||||||
setIsPaused(!atBottom)
|
setIsPaused(!atTop)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFilter(severity: SeverityFilter) {
|
function toggleFilter(severity: SeverityFilter) {
|
||||||
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<XIcon size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
className={styles.resumeBtn}
|
className={styles.resumeBtn}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPaused(false)
|
setIsPaused(false)
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↓ Resume auto-scroll
|
↑ Scroll to latest
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ChangeEvent } from 'react'
|
import { useState, type ChangeEvent } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './FilterBar.module.css'
|
import styles from './FilterBar.module.css'
|
||||||
import { Input } from '../../primitives/Input/Input'
|
import { Input } from '../../primitives/Input/Input'
|
||||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||||
@@ -77,12 +78,7 @@ export function FilterBar({
|
|||||||
if (onSearchChange) onSearchChange('')
|
if (onSearchChange) onSearchChange('')
|
||||||
else setInternalSearch('')
|
else setInternalSearch('')
|
||||||
} : undefined}
|
} : undefined}
|
||||||
icon={
|
icon={<Search size={13} />}
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import styles from './KpiStrip.module.css'
|
import styles from './KpiStrip.module.css'
|
||||||
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
|
||||||
export interface KpiItem {
|
export interface KpiItem {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
sparkline?: number[]
|
sparkline?: number[]
|
||||||
borderColor?: string
|
borderColor?: string
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
.container {
|
.container {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg-inset);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -58,6 +56,11 @@
|
|||||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.levelTrace {
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const entries: LogEntry[] = [
|
|||||||
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
||||||
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
||||||
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('LogViewer', () => {
|
describe('LogViewer', () => {
|
||||||
@@ -16,14 +17,16 @@ describe('LogViewer', () => {
|
|||||||
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
|
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
|
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
|
||||||
render(<LogViewer entries={entries} />)
|
render(<LogViewer entries={entries} />)
|
||||||
expect(screen.getByText('INFO')).toBeInTheDocument()
|
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||||
expect(screen.getByText('WARN')).toBeInTheDocument()
|
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||||
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||||
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('TRACE')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders with custom maxHeight (number)', () => {
|
it('renders with custom maxHeight (number)', () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
|
|||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
level: 'info' | 'warn' | 'error' | 'debug'
|
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
|||||||
warn: styles.levelWarn,
|
warn: styles.levelWarn,
|
||||||
error: styles.levelError,
|
error: styles.levelError,
|
||||||
debug: styles.levelDebug,
|
debug: styles.levelDebug,
|
||||||
|
trace: styles.levelTrace,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
@@ -35,18 +36,18 @@ function formatTime(iso: string): string {
|
|||||||
|
|
||||||
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const isAtBottomRef = useRef(true)
|
const isAtTopRef = useRef(true)
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
isAtTopRef.current = el.scrollTop < 20
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el && isAtBottomRef.current) {
|
if (el && isAtTopRef.current) {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [entries])
|
}, [entries])
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,58 @@
|
|||||||
padding: 2px 0 2px 4px;
|
padding: 2px 0 2px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Action trigger — hidden by default, shown on hover/selected */
|
||||||
|
.actionsTrigger {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .actionsTrigger,
|
||||||
|
.actionsVisible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.1s;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeInfo { background: var(--running); }
|
||||||
|
.badgeSuccess { background: var(--success); }
|
||||||
|
.badgeWarning { background: var(--amber); }
|
||||||
|
.badgeError { background: var(--error); }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { ProcessorTimeline } from './ProcessorTimeline'
|
||||||
|
|
||||||
|
const processors = [
|
||||||
|
{ name: 'Validate', type: 'validator', durationMs: 12, status: 'ok' as const, startMs: 0 },
|
||||||
|
{ name: 'Enrich', type: 'enricher', durationMs: 35, status: 'slow' as const, startMs: 12 },
|
||||||
|
{ name: 'Route', type: 'router', durationMs: 8, status: 'fail' as const, startMs: 47 },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ProcessorTimeline', () => {
|
||||||
|
it('renders processor names', () => {
|
||||||
|
render(<ProcessorTimeline processors={processors} totalMs={55} />)
|
||||||
|
expect(screen.getByText('Validate')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Enrich')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Route')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render action trigger when no actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline processors={processors} totalMs={55} />,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders action trigger when actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking action trigger does not fire onProcessorClick', async () => {
|
||||||
|
const onProcessorClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
onProcessorClick={onProcessorClick}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
expect(onProcessorClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls action onClick when menu item clicked', async () => {
|
||||||
|
const actionClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
await user.click(screen.getByText('Change Log Level'))
|
||||||
|
expect(actionClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports dynamic getActions per processor', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={processors}
|
||||||
|
totalMs={55}
|
||||||
|
getActions={(proc) =>
|
||||||
|
proc.status === 'fail'
|
||||||
|
? [{ label: 'View Error', onClick: () => {} }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Only the failing processor should have an action trigger
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(1)
|
||||||
|
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for Route')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './ProcessorTimeline.module.css'
|
import styles from './ProcessorTimeline.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
||||||
|
|
||||||
export interface ProcessorStep {
|
export interface ProcessorStep {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,6 +10,15 @@ export interface ProcessorStep {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
startMs: number
|
startMs: number
|
||||||
|
badges?: NodeBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessorAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessorTimelineProps {
|
interface ProcessorTimelineProps {
|
||||||
@@ -13,6 +26,8 @@ interface ProcessorTimelineProps {
|
|||||||
totalMs: number
|
totalMs: number
|
||||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: ProcessorAction[]
|
||||||
|
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +41,8 @@ export function ProcessorTimeline({
|
|||||||
totalMs,
|
totalMs,
|
||||||
onProcessorClick,
|
onProcessorClick,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
actions,
|
||||||
|
getActions,
|
||||||
className,
|
className,
|
||||||
}: ProcessorTimelineProps) {
|
}: ProcessorTimelineProps) {
|
||||||
const safeTotal = totalMs || 1
|
const safeTotal = totalMs || 1
|
||||||
@@ -70,6 +87,16 @@ export function ProcessorTimeline({
|
|||||||
>
|
>
|
||||||
<div className={styles.name} title={proc.name}>
|
<div className={styles.name} title={proc.name}>
|
||||||
{proc.name}
|
{proc.name}
|
||||||
|
{proc.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.barBg}>
|
<div className={styles.barBg}>
|
||||||
<div
|
<div
|
||||||
@@ -82,6 +109,30 @@ export function ProcessorTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
||||||
|
{(() => {
|
||||||
|
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
|
||||||
|
if (resolvedActions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${proc.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -188,17 +188,100 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottleneck badge */
|
/* Action trigger — hidden by default, shown on hover/selected */
|
||||||
.bottleneckBadge {
|
.actionsTrigger {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node:hover .actionsTrigger,
|
||||||
|
.actionsVisible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.1s;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badgeRow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -7px;
|
top: -7px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--error);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badgeInfo { background: var(--running); }
|
||||||
|
.badgeSuccess { background: var(--success); }
|
||||||
|
.badgeWarning { background: var(--amber); }
|
||||||
|
.badgeError { background: var(--error); }
|
||||||
|
|
||||||
|
/* Node wrapper (replaces inline style) */
|
||||||
|
.nodeWrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-flow sections */
|
||||||
|
.flowSection {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowSectionSeparated {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 2px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLabelDefault { color: var(--text-muted); }
|
||||||
|
.flowLabelError { color: var(--error); }
|
||||||
|
.flowLabelWarning { color: var(--warning); }
|
||||||
|
.flowLabelInfo { color: var(--running); }
|
||||||
|
|||||||
160
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
160
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { RouteFlow } from './RouteFlow'
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
{ name: 'jms:orders', type: 'from' as const, durationMs: 4, status: 'ok' as const },
|
||||||
|
{ name: 'OrderValidator', type: 'process' as const, durationMs: 8, status: 'ok' as const },
|
||||||
|
{ name: 'http:payment-api', type: 'to' as const, durationMs: 187, status: 'slow' as const },
|
||||||
|
{ name: 'dead-letter:failed', type: 'error-handler' as const, durationMs: 14, status: 'fail' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('RouteFlow', () => {
|
||||||
|
it('renders node names', () => {
|
||||||
|
render(<RouteFlow nodes={nodes} />)
|
||||||
|
expect(screen.getByText('jms:orders')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('OrderValidator')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('http:payment-api')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('dead-letter:failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render action trigger when no actions provided', () => {
|
||||||
|
const { container } = render(<RouteFlow nodes={nodes} />)
|
||||||
|
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders action trigger on all nodes including error handlers when actions provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
actions={[{ label: 'View Config', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(4) // 3 main + 1 error handler
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking action trigger does not fire onNodeClick', async () => {
|
||||||
|
const onNodeClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
expect(onNodeClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls action onClick when menu item clicked', async () => {
|
||||||
|
const actionClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
|
||||||
|
await user.click(trigger)
|
||||||
|
await user.click(screen.getByText('Change Log Level'))
|
||||||
|
expect(actionClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports dynamic getActions per node', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
getActions={(node) =>
|
||||||
|
node.type === 'process'
|
||||||
|
? [{ label: 'Edit Processor', onClick: () => {} }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(1)
|
||||||
|
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const multiFlows = [
|
||||||
|
{
|
||||||
|
label: 'Main Route',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'timer:tick', type: 'from' as const, durationMs: 0, status: 'ok' as const },
|
||||||
|
{ name: 'Processor1', type: 'process' as const, durationMs: 8, status: 'ok' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'onException',
|
||||||
|
variant: 'error' as const,
|
||||||
|
nodes: [
|
||||||
|
{ name: 'LogHandler', type: 'process' as const, durationMs: 3, status: 'ok' as const },
|
||||||
|
{ name: 'dead-letter:errors', type: 'to' as const, durationMs: 8, status: 'fail' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('RouteFlow (multi-flow)', () => {
|
||||||
|
it('renders all segment labels', () => {
|
||||||
|
render(<RouteFlow flows={multiFlows} />)
|
||||||
|
expect(screen.getByText('Main Route')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('onException')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all nodes across segments', () => {
|
||||||
|
render(<RouteFlow flows={multiFlows} />)
|
||||||
|
expect(screen.getByText('timer:tick')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Processor1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('LogHandler')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('dead-letter:errors')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses global flat indexing for onNodeClick', async () => {
|
||||||
|
const onNodeClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<RouteFlow flows={multiFlows} onNodeClick={onNodeClick} />)
|
||||||
|
// Click the first node of the second flow (global index = 2)
|
||||||
|
await user.click(screen.getByText('LogHandler'))
|
||||||
|
expect(onNodeClick).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'LogHandler' }),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectedIndex highlights correct node across flows', () => {
|
||||||
|
const { container } = render(<RouteFlow flows={multiFlows} selectedIndex={3} />)
|
||||||
|
// Index 3 = dead-letter:errors (2nd node of 2nd flow)
|
||||||
|
const selectedNodes = container.querySelectorAll('[class*="nodeSelected"]')
|
||||||
|
expect(selectedNodes.length).toBe(1)
|
||||||
|
expect(selectedNodes[0]).toHaveTextContent('dead-letter:errors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('actions work in multi-flow mode', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
flows={multiFlows}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flows takes precedence over nodes', () => {
|
||||||
|
render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
flows={multiFlows}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Should render flow content, not nodes content
|
||||||
|
expect(screen.getByText('Main Route')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('jms:orders')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './RouteFlow.module.css'
|
import styles from './RouteFlow.module.css'
|
||||||
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
|
||||||
|
export interface NodeBadge {
|
||||||
|
label: string
|
||||||
|
variant?: 'info' | 'success' | 'warning' | 'error'
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface RouteNode {
|
export interface RouteNode {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,12 +15,30 @@ export interface RouteNode {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
status: 'ok' | 'slow' | 'fail'
|
status: 'ok' | 'slow' | 'fail'
|
||||||
isBottleneck?: boolean
|
isBottleneck?: boolean
|
||||||
|
badges?: NodeBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeAction {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
divider?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowSegment {
|
||||||
|
label: string
|
||||||
|
nodes: RouteNode[]
|
||||||
|
variant?: 'default' | 'error' | 'warning' | 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteFlowProps {
|
interface RouteFlowProps {
|
||||||
nodes: RouteNode[]
|
nodes?: RouteNode[]
|
||||||
|
flows?: FlowSegment[]
|
||||||
onNodeClick?: (node: RouteNode, index: number) => void
|
onNodeClick?: (node: RouteNode, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
|
actions?: NodeAction[]
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +56,12 @@ function durationClass(ms: number, status: string): string {
|
|||||||
return styles.durBreach
|
return styles.durBreach
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||||
'from': '\u25B6',
|
'from': <Play size={14} />,
|
||||||
'process': '\u2699',
|
'process': <Cog size={14} />,
|
||||||
'to': '\u25A2',
|
'to': <Square size={14} />,
|
||||||
'choice': '\u25C6',
|
'choice': <Diamond size={14} />,
|
||||||
'error-handler': '\u26A0',
|
'error-handler': <AlertTriangle size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_CLASSES: Record<string, string> = {
|
const ICON_CLASSES: Record<string, string> = {
|
||||||
@@ -52,12 +79,141 @@ function nodeStatusClass(node: RouteNode): string {
|
|||||||
return styles.nodeHealthy
|
return styles.nodeHealthy
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
|
function renderActionTrigger(
|
||||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
node: RouteNode,
|
||||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
index: number,
|
||||||
|
isSelected: boolean,
|
||||||
|
actions?: NodeAction[],
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||||
|
) {
|
||||||
|
const resolvedActions = getActions ? getActions(node, index) : (actions ?? [])
|
||||||
|
if (resolvedActions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.actionsBtn}
|
||||||
|
aria-label={`Actions for ${node.name}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={resolvedActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOW_LABEL_CLASSES: Record<string, string> = {
|
||||||
|
'default': styles.flowLabelDefault,
|
||||||
|
'error': styles.flowLabelError,
|
||||||
|
'warning': styles.flowLabelWarning,
|
||||||
|
'info': styles.flowLabelInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeChain(
|
||||||
|
nodes: RouteNode[],
|
||||||
|
globalIndexOffset: number,
|
||||||
|
onNodeClick?: RouteFlowProps['onNodeClick'],
|
||||||
|
selectedIndex?: number,
|
||||||
|
actions?: NodeAction[],
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||||
|
) {
|
||||||
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
|
return nodes.map((node, i) => {
|
||||||
|
const globalIndex = globalIndexOffset + i
|
||||||
|
const isSelected = selectedIndex === globalIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
|
{i > 0 && (
|
||||||
|
<div className={styles.connector}>
|
||||||
|
<div className={styles.connectorLine} />
|
||||||
|
<div className={styles.connectorArrow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
|
||||||
|
onClick={() => onNodeClick?.(node, globalIndex)}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault()
|
||||||
|
onNodeClick?.(node, globalIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(node.isBottleneck || node.badges?.length) ? (
|
||||||
|
<span className={styles.badgeRow}>
|
||||||
|
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||||
|
{node.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.type}>{node.type}</div>
|
||||||
|
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||||
|
{formatDuration(node.durationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
||||||
|
// Multi-flow mode
|
||||||
|
if (flows && flows.length > 0) {
|
||||||
|
let globalOffset = 0
|
||||||
|
return (
|
||||||
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
|
{flows.map((flow, fi) => {
|
||||||
|
const sectionOffset = globalOffset
|
||||||
|
globalOffset += flow.nodes.length
|
||||||
|
const variant = flow.variant ?? 'default'
|
||||||
|
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
|
||||||
|
return (
|
||||||
|
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
|
||||||
|
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
|
||||||
|
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy mode (single nodes array with automatic error-handler separation)
|
||||||
|
const allNodes = nodes ?? []
|
||||||
|
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
|
||||||
|
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
|
||||||
|
|
||||||
// Map from mainNodes index back to original nodes index
|
// Map from mainNodes index back to original nodes index
|
||||||
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
|
const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
|
||||||
if (n.type !== 'error-handler') acc.push(idx)
|
if (n.type !== 'error-handler') acc.push(idx)
|
||||||
return acc
|
return acc
|
||||||
}, [])
|
}, [])
|
||||||
@@ -70,7 +226,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
const isClickable = !!onNodeClick
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
{i > 0 && (
|
{i > 0 && (
|
||||||
<div className={styles.connector}>
|
<div className={styles.connector}>
|
||||||
<div className={styles.connectorLine} />
|
<div className={styles.connectorLine} />
|
||||||
@@ -89,9 +245,23 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
|
{(node.isBottleneck || node.badges?.length) ? (
|
||||||
|
<span className={styles.badgeRow}>
|
||||||
|
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||||
|
{node.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<div className={styles.type}>{node.type}</div>
|
<div className={styles.type}>{node.type}</div>
|
||||||
@@ -102,6 +272,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{formatDuration(node.durationMs)}
|
{formatDuration(node.durationMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -110,7 +281,9 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{errorHandlers.length > 0 && (
|
{errorHandlers.length > 0 && (
|
||||||
<div className={styles.errorSection}>
|
<div className={styles.errorSection}>
|
||||||
<div className={styles.errorLabel}>Error Handler</div>
|
<div className={styles.errorLabel}>Error Handler</div>
|
||||||
{errorHandlers.map((node, i) => (
|
{errorHandlers.map((node, i) => {
|
||||||
|
const errOriginalIndex = allNodes.indexOf(node)
|
||||||
|
return (
|
||||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||||
{TYPE_ICONS['error-handler']}
|
{TYPE_ICONS['error-handler']}
|
||||||
@@ -124,8 +297,10 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{formatDuration(node.durationMs)}
|
{formatDuration(node.durationMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
||||||
|
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for success variant', () => {
|
it('shows correct icon for success variant', () => {
|
||||||
@@ -97,7 +97,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
||||||
|
|
||||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for warning variant', () => {
|
it('shows correct icon for warning variant', () => {
|
||||||
@@ -105,7 +105,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
||||||
|
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for error variant', () => {
|
it('shows correct icon for error variant', () => {
|
||||||
@@ -113,7 +113,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
||||||
|
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dismisses toast when close button is clicked', () => {
|
it('dismisses toast when close button is clicked', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Toast.module.css'
|
import styles from './Toast.module.css'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
|
|||||||
const DEFAULT_DURATION = 5000
|
const DEFAULT_DURATION = 5000
|
||||||
const EXIT_ANIMATION_MS = 300
|
const EXIT_ANIMATION_MS = 300
|
||||||
|
|
||||||
const ICONS: Record<ToastVariant, string> = {
|
const ICONS: Record<ToastVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context ────────────────────────────────────────────────────────────────
|
// ── Context ────────────────────────────────────────────────────────────────
|
||||||
@@ -56,6 +57,10 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
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) => {
|
const dismiss = useCallback((id: string) => {
|
||||||
// Clear auto-dismiss timer if running
|
// Clear auto-dismiss timer if running
|
||||||
const timer = timersRef.current.get(id)
|
const timer = timersRef.current.get(id)
|
||||||
@@ -70,10 +75,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Remove after animation completes
|
// Remove after animation completes
|
||||||
setTimeout(() => {
|
setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
}, [removeToast])
|
||||||
}, EXIT_ANIMATION_MS)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toast = useCallback(
|
const toast = useCallback(
|
||||||
(options: ToastOptions): string => {
|
(options: ToastOptions): string => {
|
||||||
@@ -183,7 +186,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
|
|||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,52 @@ function flattenVisibleNodes(
|
|||||||
return result
|
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 {
|
interface TreeViewProps {
|
||||||
nodes: TreeNode[]
|
nodes: TreeNode[]
|
||||||
onSelect?: (id: string) => void
|
onSelect?: (id: string) => void
|
||||||
@@ -105,68 +151,13 @@ export function TreeView({
|
|||||||
const current = visibleNodes[currentIndex]
|
const current = visibleNodes[currentIndex]
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
|
||||||
e.preventDefault()
|
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
|
||||||
const next = visibleNodes[currentIndex + 1]
|
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
|
||||||
if (next) focusNode(next.node.id)
|
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
|
||||||
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 'ArrowUp': {
|
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -239,6 +230,10 @@ function TreeNodeRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li role="none">
|
<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
|
<div
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
|
|||||||
@@ -32,12 +32,16 @@ export { MultiSelect } from './MultiSelect/MultiSelect'
|
|||||||
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||||
export { Popover } from './Popover/Popover'
|
export { Popover } from './Popover/Popover'
|
||||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
export type { RouteNode, NodeAction, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
|
||||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||||
export { SplitPane } from './SplitPane/SplitPane'
|
export { SplitPane } from './SplitPane/SplitPane'
|
||||||
export { Tabs } from './Tabs/Tabs'
|
export { Tabs } from './Tabs/Tabs'
|
||||||
export { ToastProvider, useToast } from './Toast/Toast'
|
export { ToastProvider, useToast } from './Toast/Toast'
|
||||||
export { TreeView } from './TreeView/TreeView'
|
export { TreeView } from './TreeView/TreeView'
|
||||||
|
|
||||||
|
// Chart utilities for consumers using Recharts or custom charts
|
||||||
|
export { CHART_COLORS } from './_chart-utils'
|
||||||
|
export type { ChartSeries, DataPoint } from './_chart-utils'
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ export * from './layout'
|
|||||||
export * from './providers/ThemeProvider'
|
export * from './providers/ThemeProvider'
|
||||||
export * from './providers/CommandPaletteProvider'
|
export * from './providers/CommandPaletteProvider'
|
||||||
export * from './providers/GlobalFilterProvider'
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider'
|
||||||
|
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
|
||||||
export * from './utils/hashColor'
|
export * from './utils/hashColor'
|
||||||
export * from './utils/timePresets'
|
export * from './utils/timePresets'
|
||||||
|
export * from './utils/rechartsTheme'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
|
|||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
sidebar: ReactNode
|
sidebar: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
|
||||||
detail?: ReactNode
|
detail?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ sidebar, children, detail }: AppShellProps) {
|
export function AppShell({ sidebar, children }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{detail}
|
<div id="cameleer-detail-panel-root" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,36 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
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 */
|
/* Logo */
|
||||||
@@ -15,6 +45,12 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .logo {
|
||||||
|
padding: 16px 0;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoImg {
|
.logoImg {
|
||||||
@@ -106,71 +142,40 @@
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollable nav area */
|
/* Section icon (collapsed rail) */
|
||||||
.navArea {
|
.sectionIcon {
|
||||||
flex: 1;
|
display: flex;
|
||||||
overflow-y: auto;
|
align-items: center;
|
||||||
min-height: 0;
|
justify-content: center;
|
||||||
}
|
width: 16px;
|
||||||
|
|
||||||
/* Section headers */
|
|
||||||
.section {
|
|
||||||
padding: 14px 12px 5px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Items container */
|
/* Rail item (collapsed sidebar section) */
|
||||||
.items {
|
.sectionRailItem {
|
||||||
padding: 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nav item (flat links like Dashboards) */
|
|
||||||
.item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
padding: 7px 12px;
|
padding: 10px 0;
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--sidebar-text);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.12s;
|
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
margin-bottom: 1px;
|
transition: background 0.12s;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover {
|
.sectionRailItem:hover {
|
||||||
background: var(--sidebar-hover);
|
background: var(--sidebar-hover);
|
||||||
color: #e8dfd4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item.active {
|
.sectionRailItemActive {
|
||||||
background: var(--sidebar-active);
|
|
||||||
color: var(--amber);
|
|
||||||
border-left-color: var(--amber);
|
border-left-color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navIcon {
|
.sectionRailItemActive .sectionIcon {
|
||||||
font-size: 14px;
|
|
||||||
width: 18px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--sidebar-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item.active .navIcon {
|
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.routeArrow {
|
.treeSectionActive {
|
||||||
color: var(--sidebar-muted);
|
border-left-color: var(--amber);
|
||||||
font-size: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Item sub-elements */
|
/* Item sub-elements */
|
||||||
@@ -200,15 +205,7 @@
|
|||||||
padding: 0 6px 6px;
|
padding: 0 6px 6px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
border-left: 3px solid transparent;
|
||||||
|
|
||||||
.treeSectionLabel {
|
|
||||||
padding: 10px 12px 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: var(--sidebar-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapsible section toggle */
|
/* Collapsible section toggle */
|
||||||
@@ -218,6 +215,8 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0 4px;
|
padding: 8px 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionChevronBtn {
|
.treeSectionChevronBtn {
|
||||||
@@ -383,100 +382,13 @@
|
|||||||
color: var(--amber);
|
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 links ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomItem {
|
.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 { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { Sidebar, type SidebarApp } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import { ThemeProvider } from '../../providers/ThemeProvider'
|
import { ThemeProvider } from '../../providers/ThemeProvider'
|
||||||
|
|
||||||
const TEST_APPS: SidebarApp[] = [
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
{
|
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) {
|
const LogoIcon = () => <svg data-testid="logo-icon" />
|
||||||
return render(
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
<Sidebar apps={TEST_APPS} {...props} />
|
</ThemeProvider>
|
||||||
</MemoryRouter>
|
|
||||||
</ThemeProvider>,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Sidebar', () => {
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
describe('Sidebar compound component', () => {
|
||||||
sessionStorage.clear()
|
// 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', () => {
|
// 2. hides Header title and version when collapsed
|
||||||
renderSidebar()
|
it('hides Header title and version when sidebar is collapsed', () => {
|
||||||
expect(screen.getByText('cameleer')).toBeInTheDocument()
|
render(
|
||||||
expect(screen.getByText('v3.2.1')).toBeInTheDocument()
|
<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', () => {
|
// 3. renders Section with label and children
|
||||||
renderSidebar()
|
it('renders Section with label and children when open', () => {
|
||||||
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument()
|
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', () => {
|
// 4. hides Section children when section collapsed (open=false)
|
||||||
renderSidebar()
|
it('hides Section children when section is not open', () => {
|
||||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
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', () => {
|
// 5. calls onToggle when Section header clicked
|
||||||
renderSidebar()
|
it('calls onToggle when Section chevron button is clicked', async () => {
|
||||||
expect(screen.getByText('Applications')).toBeInTheDocument()
|
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', () => {
|
// 6. renders collapse toggle and calls onCollapseToggle
|
||||||
renderSidebar()
|
it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
|
||||||
expect(screen.getByText('Agents')).toBeInTheDocument()
|
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', () => {
|
// 7. renders expand toggle label when collapsed
|
||||||
renderSidebar()
|
it('renders expand toggle when sidebar is collapsed', () => {
|
||||||
expect(screen.getByText('Routes')).toBeInTheDocument()
|
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', () => {
|
// 8. renders search input and calls onSearchChange
|
||||||
renderSidebar()
|
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('Admin')).toBeInTheDocument()
|
||||||
expect(screen.getByText('API Docs')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders app names in the Applications tree', () => {
|
// 12. hides FooterLink labels when collapsed and sets title tooltip
|
||||||
renderSidebar()
|
it('hides FooterLink label when collapsed and exposes title tooltip', () => {
|
||||||
// order-service appears in Applications, Routes, and Agents trees
|
render(
|
||||||
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
<Wrapper>
|
||||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
<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', () => {
|
// 13. calls FooterLink onClick
|
||||||
renderSidebar()
|
it('calls FooterLink onClick when clicked', async () => {
|
||||||
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 () => {
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderSidebar()
|
const onClick = vi.fn()
|
||||||
|
|
||||||
// Find the first app row (order-service in Applications tree) and hover to reveal star
|
render(
|
||||||
const appRows = screen.getAllByText('order-service')
|
<Wrapper>
|
||||||
const appRow = appRows[0].closest('[role="treeitem"]')!
|
<Sidebar>
|
||||||
await user.hover(appRow)
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
// Click the star button
|
await user.click(screen.getByText('Admin'))
|
||||||
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')!
|
expect(onClick).toHaveBeenCalledTimes(1)
|
||||||
await user.click(starBtn)
|
|
||||||
|
|
||||||
expect(screen.getByText('★ Starred')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
const user = userEvent.setup()
|
||||||
renderSidebar()
|
const onCollapseToggle = vi.fn()
|
||||||
|
const onToggle = vi.fn()
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Filter...')
|
render(
|
||||||
await user.type(searchInput, 'payment')
|
<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)
|
const railItem = screen.getByTitle('Settings')
|
||||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
await user.click(railItem)
|
||||||
|
|
||||||
|
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('expands tree to show children when chevron is clicked', async () => {
|
// 16. applies active highlight to FooterLink
|
||||||
const user = userEvent.setup()
|
it('applies active highlight class to FooterLink when active', () => {
|
||||||
renderSidebar()
|
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 item = screen.getByText('Admin').closest('[role="button"]')!
|
||||||
const expandBtns = screen.getAllByLabelText('Expand')
|
expect(item.className).toMatch(/bottomItemActive/)
|
||||||
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')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,563 +1,253 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { type ReactNode, Children, isValidElement } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import {
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
} from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
||||||
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
|
||||||
import { useStarred } from './useStarred'
|
|
||||||
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Sub-component props ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SidebarApp {
|
interface SidebarHeaderProps {
|
||||||
id: string
|
logo: ReactNode
|
||||||
name: string
|
title: string
|
||||||
health: 'live' | 'stale' | 'dead'
|
version?: string
|
||||||
exchangeCount: number
|
onClick?: () => void
|
||||||
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[]
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
interface SidebarSectionProps {
|
||||||
|
icon: ReactNode
|
||||||
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
|
|
||||||
label: string
|
label: string
|
||||||
icon?: React.ReactNode
|
open: boolean
|
||||||
path: string
|
onToggle: () => void
|
||||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
active?: boolean
|
||||||
parentApp?: string
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
|
interface SidebarFooterProps {
|
||||||
const items: StarredItem[] = []
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
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 SidebarFooterLinkProps {
|
||||||
}
|
icon: ReactNode
|
||||||
|
|
||||||
// ── StarredGroup ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StarredGroup({
|
|
||||||
label,
|
|
||||||
items,
|
|
||||||
onNavigate,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
label: string
|
label: string
|
||||||
items: StarredItem[]
|
active?: boolean
|
||||||
onNavigate: (path: string) => void
|
onClick?: () => void
|
||||||
onRemove: (starKey: string) => 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 (
|
return (
|
||||||
<div className={styles.starredGroup}>
|
|
||||||
<div className={styles.starredGroupLabel}>{label}</div>
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
<div
|
||||||
key={item.starKey}
|
className={`${styles.logo} ${className ?? ''}`}
|
||||||
className={styles.starredItem}
|
onClick={onClick}
|
||||||
onClick={() => onNavigate(item.path)}
|
style={onClick ? { cursor: 'pointer' } : undefined}
|
||||||
role="button"
|
role={onClick ? 'button' : undefined}
|
||||||
tabIndex={0}
|
tabIndex={onClick ? 0 : undefined}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
|
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{logo}
|
||||||
<div className={styles.starredItemInfo}>
|
{!collapsed && (
|
||||||
<span className={styles.starredItemName}>{item.label}</span>
|
<div>
|
||||||
{item.parentApp && (
|
<span className={styles.brand}>{title}</span>
|
||||||
<span className={styles.starredItemContext}>{item.parentApp}</span>
|
{version && <span className={styles.version}>{version}</span>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
)
|
||||||
className={styles.starredRemove}
|
}
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
|
|
||||||
tabIndex={-1}
|
function SidebarSection({
|
||||||
aria-label={`Remove ${item.label} from starred`}
|
icon,
|
||||||
|
label,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
const { collapsed, onCollapseToggle } = useSidebarContext()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<span className={styles.sectionIcon}>{icon}</span>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
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 (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
<div
|
||||||
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
|
className={styles.treeSectionToggle}
|
||||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
onClick={onToggle}
|
||||||
<div>
|
role="button"
|
||||||
<span className={styles.brand}>cameleer</span>
|
tabIndex={0}
|
||||||
<span className={styles.version}>v3.2.1</span>
|
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>
|
</div>
|
||||||
|
{open && children}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* Search */}
|
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.searchWrap}>
|
||||||
<div className={styles.searchInner}>
|
<div className={styles.searchInner}>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<Search size={12} />
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className={styles.searchInput}
|
className={styles.searchInput}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter..."
|
placeholder="Filter..."
|
||||||
value={search}
|
value={searchValue ?? ''}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{search && (
|
{searchValue && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.searchClear}
|
className={styles.searchClear}
|
||||||
onClick={() => setSearch('')}
|
onClick={() => onSearchChange('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
{rest}
|
||||||
|
</>
|
||||||
{/* 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>
|
</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
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,
|
type MouseEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -33,24 +34,17 @@ export interface SidebarTreeProps {
|
|||||||
filterQuery?: string
|
filterQuery?: string
|
||||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||||
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
// ── Star icons ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StarOutline() {
|
function StarOutline() {
|
||||||
return (
|
return <Star size={14} />
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarFilled() {
|
function StarFilled() {
|
||||||
return (
|
return <Star size={14} fill="currentColor" />
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistent expand state ──────────────────────────────────────────────────
|
// ── Persistent expand state ──────────────────────────────────────────────────
|
||||||
@@ -130,6 +124,52 @@ function filterNodes(
|
|||||||
return { filtered: walk(nodes), matchedParentIds }
|
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 ──────────────────────────────────────────────────────────────
|
// ── SidebarTree ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SidebarTree({
|
export function SidebarTree({
|
||||||
@@ -141,8 +181,10 @@ export function SidebarTree({
|
|||||||
filterQuery,
|
filterQuery,
|
||||||
persistKey,
|
persistKey,
|
||||||
autoRevealPath,
|
autoRevealPath,
|
||||||
|
onNavigate,
|
||||||
}: SidebarTreeProps) {
|
}: SidebarTreeProps) {
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const navigate = onNavigate ?? routerNavigate
|
||||||
|
|
||||||
// Expand/collapse state — optionally persisted to sessionStorage
|
// Expand/collapse state — optionally persisted to sessionStorage
|
||||||
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
||||||
@@ -226,64 +268,13 @@ export function SidebarTree({
|
|||||||
const current = visibleNodes[currentIndex]
|
const current = visibleNodes[currentIndex]
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
|
||||||
e.preventDefault()
|
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
|
||||||
const next = visibleNodes[currentIndex + 1]
|
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
|
||||||
if (next) focusNode(next.node.id)
|
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
|
||||||
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 'ArrowUp': {
|
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -375,6 +366,10 @@ function SidebarTreeRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li role="none">
|
<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
|
<div
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
@@ -395,7 +390,7 @@ function SidebarTreeRow({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
>
|
>
|
||||||
{isExpanded ? '▾' : '▸'}
|
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Search, Moon, Sun, Power } from 'lucide-react'
|
||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||||
@@ -8,11 +9,8 @@ import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeD
|
|||||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
||||||
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||||
import { useTheme } from '../../providers/ThemeProvider'
|
import { useTheme } from '../../providers/ThemeProvider'
|
||||||
|
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||||
interface BreadcrumbItem {
|
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||||
label: string
|
|
||||||
href?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
@@ -39,11 +37,12 @@ export function TopBar({
|
|||||||
const globalFilters = useGlobalFilters()
|
const globalFilters = useGlobalFilters()
|
||||||
const commandPalette = useCommandPalette()
|
const commandPalette = useCommandPalette()
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const breadcrumbOverride = useBreadcrumbOverride()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||||
|
|
||||||
{/* Search trigger */}
|
{/* Search trigger */}
|
||||||
<button
|
<button
|
||||||
@@ -53,10 +52,7 @@ export function TopBar({
|
|||||||
aria-label="Open search"
|
aria-label="Open search"
|
||||||
>
|
>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<Search size={13} />
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||||
<span className={styles.kbd}>Ctrl+K</span>
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
@@ -94,7 +90,7 @@ export function TopBar({
|
|||||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||||
>
|
>
|
||||||
<span className={styles.liveDot} />
|
<span className={styles.liveDot} />
|
||||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.themeToggle}
|
className={styles.themeToggle}
|
||||||
@@ -103,7 +99,7 @@ export function TopBar({
|
|||||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
>
|
>
|
||||||
{theme === 'light' ? '\u263E' : '\u2600'}
|
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
||||||
</button>
|
</button>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<span className={styles.env}>{environment}</span>
|
||||||
@@ -117,7 +113,7 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={[
|
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 { AppShell } from './AppShell/AppShell'
|
||||||
export { Sidebar } from './Sidebar/Sidebar'
|
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'
|
export { TopBar } from './TopBar/TopBar'
|
||||||
|
|||||||
@@ -46,24 +46,23 @@ describe('Alert', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows default icon for each variant', () => {
|
it('shows default icon for each variant', () => {
|
||||||
const { rerender } = render(<Alert variant="info">msg</Alert>)
|
const { container, rerender } = render(<Alert variant="info">msg</Alert>)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
// Each variant should render an SVG icon in the icon slot
|
||||||
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="success">msg</Alert>)
|
rerender(<Alert variant="success">msg</Alert>)
|
||||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="warning">msg</Alert>)
|
rerender(<Alert variant="warning">msg</Alert>)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="error">msg</Alert>)
|
rerender(<Alert variant="error">msg</Alert>)
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a custom icon when provided', () => {
|
it('renders a custom icon when provided', () => {
|
||||||
render(<Alert icon={<span>★</span>}>Custom icon alert</Alert>)
|
render(<Alert icon={<span data-testid="custom-icon">★</span>}>Custom icon alert</Alert>)
|
||||||
expect(screen.getByText('★')).toBeInTheDocument()
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||||
// Default icon should not appear
|
|
||||||
expect(screen.queryByText('ℹ')).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show dismiss button when dismissible is false', () => {
|
it('does not show dismiss button when dismissible is false', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Alert.module.css'
|
import styles from './Alert.module.css'
|
||||||
|
|
||||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||||
@@ -13,11 +14,11 @@ interface AlertProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<AlertVariant, string> = {
|
const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
||||||
@@ -61,7 +62,7 @@ export function Alert({
|
|||||||
aria-label="Dismiss alert"
|
aria-label="Dismiss alert"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ export function InlineEdit({ value, onSave, placeholder, disabled, className }:
|
|||||||
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
||||||
<span
|
<span
|
||||||
className={isEmpty ? styles.placeholder : styles.value}
|
className={isEmpty ? styles.placeholder : styles.value}
|
||||||
|
role="button"
|
||||||
|
tabIndex={disabled ? undefined : 0}
|
||||||
onClick={startEdit}
|
onClick={startEdit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
startEdit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isEmpty ? placeholder : value}
|
{isEmpty ? placeholder : value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import styles from './Input.module.css'
|
import styles from './Input.module.css'
|
||||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbContextValue {
|
||||||
|
override: BreadcrumbItem[] | null
|
||||||
|
setOverride: (items: BreadcrumbItem[] | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BreadcrumbContext = createContext<BreadcrumbContextValue>({
|
||||||
|
override: null,
|
||||||
|
setOverride: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [override, setOverride] = useState<BreadcrumbItem[] | null>(null)
|
||||||
|
return (
|
||||||
|
<BreadcrumbContext.Provider value={{ override, setOverride }}>
|
||||||
|
{children}
|
||||||
|
</BreadcrumbContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the TopBar breadcrumb with page-specific semantic items.
|
||||||
|
* Pass `null` to clear (or let unmount handle it).
|
||||||
|
* Callers should `useMemo` the items array to avoid unnecessary re-renders.
|
||||||
|
*/
|
||||||
|
export function useBreadcrumb(items: BreadcrumbItem[] | null) {
|
||||||
|
const { setOverride } = useContext(BreadcrumbContext)
|
||||||
|
useEffect(() => {
|
||||||
|
setOverride(items)
|
||||||
|
return () => setOverride(null)
|
||||||
|
}, [items, setOverride])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal — used by TopBar to read the current override. */
|
||||||
|
export function useBreadcrumbOverride(): BreadcrumbItem[] | null {
|
||||||
|
return useContext(BreadcrumbContext).override
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||||
import { computePresetRange } from '../utils/timePresets'
|
import { computePresetRange } from '../utils/timePresets'
|
||||||
|
|
||||||
export interface TimeRange {
|
export interface TimeRange {
|
||||||
@@ -12,6 +12,7 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
|||||||
interface GlobalFilterContextValue {
|
interface GlobalFilterContextValue {
|
||||||
timeRange: TimeRange
|
timeRange: TimeRange
|
||||||
setTimeRange: (range: TimeRange) => void
|
setTimeRange: (range: TimeRange) => void
|
||||||
|
refreshTimeRange: () => void
|
||||||
statusFilters: Set<ExchangeStatus>
|
statusFilters: Set<ExchangeStatus>
|
||||||
toggleStatus: (status: ExchangeStatus) => void
|
toggleStatus: (status: ExchangeStatus) => void
|
||||||
clearStatusFilters: () => void
|
clearStatusFilters: () => void
|
||||||
@@ -66,6 +67,24 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Keep the time range sliding forward when a preset is active and live
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh || !timeRange.preset) return
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const { start, end } = computePresetRange(timeRange.preset!)
|
||||||
|
setTimeRangeState({ start, end, preset: timeRange.preset })
|
||||||
|
}, 10_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [autoRefresh, timeRange.preset])
|
||||||
|
|
||||||
|
// 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(
|
const isInTimeRange = useCallback(
|
||||||
(timestamp: Date) => {
|
(timestamp: Date) => {
|
||||||
if (timeRange.preset) {
|
if (timeRange.preset) {
|
||||||
@@ -80,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalFilterContext.Provider
|
<GlobalFilterContext.Provider
|
||||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</GlobalFilterContext.Provider>
|
</GlobalFilterContext.Provider>
|
||||||
|
|||||||
71
src/design-system/utils/rechartsTheme.ts
Normal file
71
src/design-system/utils/rechartsTheme.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { CHART_COLORS } from '../composites/_chart-utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-configured Recharts prop objects that match the design system's
|
||||||
|
* chart styling. Spread these onto Recharts sub-components:
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
* import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend } from 'recharts'
|
||||||
|
*
|
||||||
|
* <LineChart data={data}>
|
||||||
|
* <CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
* <XAxis dataKey="name" {...rechartsTheme.xAxis} />
|
||||||
|
* <YAxis {...rechartsTheme.yAxis} />
|
||||||
|
* <Tooltip {...rechartsTheme.tooltip} />
|
||||||
|
* <Legend {...rechartsTheme.legend} />
|
||||||
|
* <Line stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
|
||||||
|
* </LineChart>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const rechartsTheme = {
|
||||||
|
colors: CHART_COLORS,
|
||||||
|
|
||||||
|
cartesianGrid: {
|
||||||
|
stroke: 'var(--border-subtle)',
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
vertical: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
xAxis: {
|
||||||
|
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
|
||||||
|
axisLine: { stroke: 'var(--border-subtle)' },
|
||||||
|
tickLine: false as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
yAxis: {
|
||||||
|
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
|
||||||
|
axisLine: false as const,
|
||||||
|
tickLine: false as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
tooltip: {
|
||||||
|
contentStyle: {
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '6px 10px',
|
||||||
|
},
|
||||||
|
labelStyle: {
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
cursor: { stroke: 'var(--text-faint)' },
|
||||||
|
},
|
||||||
|
|
||||||
|
legend: {
|
||||||
|
wrapperStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
451
src/layout/LayoutShell.tsx
Normal file
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 { 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 { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
import styles from './Admin.module.css'
|
import styles from './Admin.module.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -23,7 +20,7 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Admin', href: '/admin' },
|
{ label: 'Admin', href: '/admin' },
|
||||||
@@ -40,6 +37,6 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
|
|||||||
<div className={styles.adminContent}>
|
<div className={styles.adminContent}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
import styles from './AgentHealth.module.css'
|
import styles from './AgentHealth.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
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 { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -27,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
import { agentEvents } from '../../mocks/agentEvents'
|
import { agentEvents } from '../../mocks/agentEvents'
|
||||||
|
|
||||||
// ── URL scope parsing ────────────────────────────────────────────────────────
|
// ── URL scope parsing ────────────────────────────────────────────────────────
|
||||||
@@ -316,19 +314,7 @@ export function AgentHealth() {
|
|||||||
const isFullWidth = scope.level !== 'all'
|
const isFullWidth = scope.level !== 'all'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
|
|
||||||
detail={
|
|
||||||
selectedInstance ? (
|
|
||||||
<DetailPanel
|
|
||||||
open={panelOpen}
|
|
||||||
onClose={() => setPanelOpen(false)}
|
|
||||||
title={selectedInstance.name}
|
|
||||||
tabs={detailTabs}
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={buildBreadcrumb(scope)}
|
breadcrumb={buildBreadcrumb(scope)}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
@@ -389,7 +375,7 @@ export function AgentHealth() {
|
|||||||
{scope.level !== 'all' && (
|
{scope.level !== 'all' && (
|
||||||
<>
|
<>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -453,6 +439,16 @@ export function AgentHealth() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
import styles from './AgentInstance.module.css'
|
import styles from './AgentInstance.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
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 { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -27,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
import { agents } from '../../mocks/agents'
|
import { agents } from '../../mocks/agents'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
import { agentEvents } from '../../mocks/agentEvents'
|
import { agentEvents } from '../../mocks/agentEvents'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -126,12 +124,12 @@ export function AgentInstance() {
|
|||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} />
|
<TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} />
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.notFound}>Agent instance not found.</div>
|
<div className={styles.notFound}>Agent instance not found.</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +150,7 @@ export function AgentInstance() {
|
|||||||
const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
|
const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
@@ -177,9 +175,9 @@ export function AgentInstance() {
|
|||||||
{/* Scope trail + badges */}
|
{/* Scope trail + badges */}
|
||||||
<div className={styles.scopeTrail}>
|
<div className={styles.scopeTrail}>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||||
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
||||||
<Badge label={agent.version} color="auto" variant="outlined" />
|
<Badge label={agent.version} color="auto" variant="outlined" />
|
||||||
@@ -301,6 +299,6 @@ export function AgentInstance() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
|
|
||||||
export function ApiDocs() {
|
export function ApiDocs() {
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[{ label: 'API Documentation' }]}
|
breadcrumb={[{ label: 'API Documentation' }]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
@@ -17,6 +14,6 @@ export function ApiDocs() {
|
|||||||
title="API Documentation"
|
title="API Documentation"
|
||||||
description="API documentation coming soon."
|
description="API documentation coming soon."
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { useParams } from 'react-router-dom'
|
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 { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
|
|
||||||
export function AppDetail() {
|
export function AppDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
@@ -23,6 +20,6 @@ export function AppDetail() {
|
|||||||
title="Application Detail"
|
title="Application Detail"
|
||||||
description="Application detail view coming soon."
|
description="Application detail view coming soon."
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px 24px 40px;
|
padding: 20px 24px 40px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filter bar spacing */
|
/* Filter bar spacing */
|
||||||
@@ -19,6 +22,10 @@
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableHeader {
|
.tableHeader {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
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 { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -43,10 +42,10 @@ const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
|
|||||||
warning: 'var(--warning)',
|
warning: 'var(--warning)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TREND_ICONS: Record<KpiMetric['trend'], string> = {
|
const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
|
||||||
up: '\u2191',
|
up: <TrendingUp size={12} />,
|
||||||
down: '\u2193',
|
down: <TrendingDown size={12} />,
|
||||||
neutral: '\u2192',
|
neutral: <ArrowRight size={12} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
||||||
@@ -60,7 +59,7 @@ function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' |
|
|||||||
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
||||||
label: m.label,
|
label: m.label,
|
||||||
value: m.unit ? `${m.value} ${m.unit}` : m.value,
|
value: m.unit ? `${m.value} ${m.unit}` : m.value,
|
||||||
trend: { label: `${TREND_ICONS[m.trend]} ${m.trendValue}`, variant: sentimentToVariant(m.trendSentiment) },
|
trend: { label: <><span style={{ display: 'inline-flex', verticalAlign: 'middle' }}>{TREND_ICONS[m.trend]}</span> {m.trendValue}</>, variant: sentimentToVariant(m.trendSentiment) },
|
||||||
subtitle: m.detail,
|
subtitle: m.detail,
|
||||||
sparkline: m.sparkline,
|
sparkline: m.sparkline,
|
||||||
borderColor: ACCENT_TO_COLOR[m.accent],
|
borderColor: ACCENT_TO_COLOR[m.accent],
|
||||||
@@ -206,7 +205,7 @@ export function Dashboard() {
|
|||||||
navigate(`/exchanges/${row.id}`)
|
navigate(`/exchanges/${row.id}`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↗
|
<ExternalLink size={14} />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -286,12 +285,67 @@ export function Dashboard() {
|
|||||||
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
|
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={
|
{/* Top bar */}
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
<TopBar
|
||||||
|
breadcrumb={
|
||||||
|
routeId
|
||||||
|
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
|
||||||
|
: appId
|
||||||
|
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
|
||||||
|
: [{ label: 'Applications' }]
|
||||||
}
|
}
|
||||||
detail={
|
environment="PRODUCTION"
|
||||||
selectedExchange ? (
|
user={{ name: 'hendrik' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
|
||||||
|
{/* Health strip */}
|
||||||
|
<KpiStrip items={kpiItems} />
|
||||||
|
|
||||||
|
{/* Exchanges table */}
|
||||||
|
<div className={styles.tableSection}>
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||||
|
<div className={styles.tableRight}>
|
||||||
|
<span className={styles.tableMeta}>
|
||||||
|
{filteredExchanges.length.toLocaleString()} of {scopedExchanges.length.toLocaleString()} exchanges
|
||||||
|
</span>
|
||||||
|
<Badge label="LIVE" color="success" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={COLUMNS}
|
||||||
|
data={filteredExchanges}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
selectedId={selectedId}
|
||||||
|
sortable
|
||||||
|
flush
|
||||||
|
fillHeight
|
||||||
|
rowAccent={handleRowAccent}
|
||||||
|
expandedContent={(row) =>
|
||||||
|
row.errorMessage ? (
|
||||||
|
<div className={styles.inlineError}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shortcuts bar */}
|
||||||
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||||
|
|
||||||
|
{/* Detail panel (portals itself) */}
|
||||||
|
{selectedExchange && (
|
||||||
<DetailPanel
|
<DetailPanel
|
||||||
open={panelOpen}
|
open={panelOpen}
|
||||||
onClose={() => setPanelOpen(false)}
|
onClose={() => setPanelOpen(false)}
|
||||||
@@ -303,7 +357,7 @@ export function Dashboard() {
|
|||||||
className={styles.openDetailLink}
|
className={styles.openDetailLink}
|
||||||
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
||||||
>
|
>
|
||||||
Open full details →
|
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -383,65 +437,7 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DetailPanel>
|
</DetailPanel>
|
||||||
) : undefined
|
)}
|
||||||
}
|
</>
|
||||||
>
|
|
||||||
{/* Top bar */}
|
|
||||||
<TopBar
|
|
||||||
breadcrumb={
|
|
||||||
routeId
|
|
||||||
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
|
|
||||||
: appId
|
|
||||||
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
|
|
||||||
: [{ label: 'Applications' }]
|
|
||||||
}
|
|
||||||
environment="PRODUCTION"
|
|
||||||
user={{ name: 'hendrik' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className={styles.content}>
|
|
||||||
|
|
||||||
{/* Health strip */}
|
|
||||||
<KpiStrip items={kpiItems} />
|
|
||||||
|
|
||||||
{/* Exchanges table */}
|
|
||||||
<div className={styles.tableSection}>
|
|
||||||
<div className={styles.tableHeader}>
|
|
||||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
|
||||||
<div className={styles.tableRight}>
|
|
||||||
<span className={styles.tableMeta}>
|
|
||||||
{filteredExchanges.length.toLocaleString()} of {scopedExchanges.length.toLocaleString()} exchanges
|
|
||||||
</span>
|
|
||||||
<Badge label="LIVE" color="success" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
columns={COLUMNS}
|
|
||||||
data={filteredExchanges}
|
|
||||||
onRowClick={handleRowClick}
|
|
||||||
selectedId={selectedId}
|
|
||||||
sortable
|
|
||||||
flush
|
|
||||||
rowAccent={handleRowAccent}
|
|
||||||
expandedContent={(row) =>
|
|
||||||
row.errorMessage ? (
|
|
||||||
<div className={styles.inlineError}>
|
|
||||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
|
||||||
<div>
|
|
||||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
|
||||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Shortcuts bar */}
|
|
||||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
|
||||||
</AppShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom'
|
|||||||
import styles from './ExchangeDetail.module.css'
|
import styles from './ExchangeDetail.module.css'
|
||||||
|
|
||||||
// Layout
|
// 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'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -22,7 +20,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges } from '../../mocks/exchanges'
|
import { exchanges } from '../../mocks/exchanges'
|
||||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||||
|
|
||||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
@@ -196,11 +194,7 @@ export function ExchangeDetail() {
|
|||||||
// Not found state
|
// Not found state
|
||||||
if (!exchange) {
|
if (!exchange) {
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={
|
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
@@ -213,7 +207,7 @@ export function ExchangeDetail() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
|
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,11 +223,7 @@ export function ExchangeDetail() {
|
|||||||
const isSelectedFailed = selectedProc?.status === 'fail'
|
const isSelectedFailed = selectedProc?.status === 'fail'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={
|
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
@@ -454,6 +444,6 @@ export function ExchangeDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Hexagon, ArrowRight, Diamond, Eye, Pencil, RotateCcw, Trash2, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CompositesSection.module.css'
|
import styles from './CompositesSection.module.css'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -134,13 +135,27 @@ interface TableRow {
|
|||||||
exchanges: number
|
exchanges: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABLE_DATA: TableRow[] = [
|
const ROUTE_PREFIXES = [
|
||||||
{ id: '1', name: 'order-ingest', method: 'POST', status: 'live', exchanges: 1243 },
|
'order', 'payment', 'inventory', 'customer', 'shipment', 'user', 'cart',
|
||||||
{ id: '2', name: 'payment-validate', method: 'POST', status: 'live', exchanges: 987 },
|
'refund', 'stock', 'email', 'webhook', 'cache', 'log', 'rate', 'health',
|
||||||
{ id: '3', name: 'inventory-check', method: 'GET', status: 'stale', exchanges: 432 },
|
'session', 'config', 'metrics', 'batch', 'audit', 'invoice', 'product',
|
||||||
{ id: '4', name: 'notify-customer', method: 'POST', status: 'live', exchanges: 876 },
|
'catalog', 'search', 'report', 'export', 'import', 'sync', 'alert', 'ticket',
|
||||||
{ id: '5', name: 'archive-order', method: 'PUT', status: 'dead', exchanges: 54 },
|
|
||||||
]
|
]
|
||||||
|
const ROUTE_SUFFIXES = [
|
||||||
|
'ingest', 'validate', 'check', 'notify', 'archive', 'track', 'auth',
|
||||||
|
'update', 'process', 'sync', 'dispatch', 'relay', 'invalidate', 'aggregate',
|
||||||
|
'limit', 'refresh', 'reload', 'export', 'import', 'purge',
|
||||||
|
]
|
||||||
|
const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
|
||||||
|
const STATUSES = ['live', 'live', 'live', 'live', 'stale', 'stale', 'dead']
|
||||||
|
|
||||||
|
const TABLE_DATA: TableRow[] = Array.from({ length: 500 }, (_, i) => ({
|
||||||
|
id: String(i + 1),
|
||||||
|
name: `${ROUTE_PREFIXES[i % ROUTE_PREFIXES.length]}-${ROUTE_SUFFIXES[(i * 7) % ROUTE_SUFFIXES.length]}`,
|
||||||
|
method: METHODS[i % METHODS.length],
|
||||||
|
status: STATUSES[i % STATUSES.length],
|
||||||
|
exchanges: ((i * 1337 + 421) % 9990) + 10,
|
||||||
|
}))
|
||||||
|
|
||||||
const NOW = new Date()
|
const NOW = new Date()
|
||||||
const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
|
const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
|
||||||
@@ -157,25 +172,25 @@ const TREE_NODES = [
|
|||||||
{
|
{
|
||||||
id: 'app1',
|
id: 'app1',
|
||||||
label: 'cameleer-prod',
|
label: 'cameleer-prod',
|
||||||
icon: '⬡',
|
icon: <Hexagon size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'route1',
|
id: 'route1',
|
||||||
label: 'order-ingest',
|
label: 'order-ingest',
|
||||||
icon: '→',
|
icon: <ArrowRight size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' },
|
{ id: 'proc1', label: 'ValidateOrder', icon: <Diamond size={12} />, meta: '12ms' },
|
||||||
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' },
|
{ id: 'proc2', label: 'EnrichPayload', icon: <Diamond size={12} />, meta: '8ms' },
|
||||||
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' },
|
{ id: 'proc3', label: 'RouteToQueue', icon: <Diamond size={12} />, meta: '3ms' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'route2',
|
id: 'route2',
|
||||||
label: 'payment-validate',
|
label: 'payment-validate',
|
||||||
icon: '→',
|
icon: <ArrowRight size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' },
|
{ id: 'proc4', label: 'TokenizeCard', icon: <Diamond size={12} />, meta: '22ms' },
|
||||||
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' },
|
{ id: 'proc5', label: 'AuthorizePayment', icon: <Diamond size={12} />, meta: '45ms' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -467,12 +482,13 @@ export function CompositesSection() {
|
|||||||
title="DataTable"
|
title="DataTable"
|
||||||
description="Sortable, paginated table with row click, accent rows, and page size selector."
|
description="Sortable, paginated table with row click, accent rows, and page size selector."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%', height: 500, display: 'flex', flexDirection: 'column' }}>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
data={TABLE_DATA}
|
data={TABLE_DATA}
|
||||||
sortable
|
sortable
|
||||||
pageSize={5}
|
pageSize={25}
|
||||||
|
fillHeight
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -505,13 +521,13 @@ export function CompositesSection() {
|
|||||||
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
||||||
>
|
>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={<Button size="sm" variant="secondary">Actions ▾</Button>}
|
trigger={<Button size="sm" variant="secondary">Actions <ChevronDown size={12} /></Button>}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'View details', icon: '👁', onClick: () => undefined },
|
{ label: 'View details', icon: <Eye size={14} />, onClick: () => undefined },
|
||||||
{ label: 'Edit route', icon: '✏', onClick: () => undefined },
|
{ label: 'Edit route', icon: <Pencil size={14} />, onClick: () => undefined },
|
||||||
{ divider: true, label: '' },
|
{ divider: true, label: '' },
|
||||||
{ label: 'Restart', icon: '↺', onClick: () => undefined },
|
{ label: 'Restart', icon: <RotateCcw size={14} />, onClick: () => undefined },
|
||||||
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true },
|
{ label: 'Delete', icon: <Trash2 size={14} />, onClick: () => undefined, disabled: true },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -769,7 +785,7 @@ export function CompositesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="processortimeline"
|
id="processortimeline"
|
||||||
title="ProcessorTimeline"
|
title="ProcessorTimeline"
|
||||||
description="Horizontal Gantt-style timeline showing processor execution order, duration, and status."
|
description="Horizontal Gantt-style timeline with selectable rows and optional action menus via actions or getActions prop."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<ProcessorTimeline
|
<ProcessorTimeline
|
||||||
@@ -780,6 +796,11 @@ export function CompositesSection() {
|
|||||||
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
||||||
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
||||||
]}
|
]}
|
||||||
|
getActions={(proc) => [
|
||||||
|
{ label: 'Change Log Level', onClick: () => {} },
|
||||||
|
{ label: 'View Configuration', onClick: () => {} },
|
||||||
|
...(proc.status === 'fail' ? [{ label: 'View Stack Trace', onClick: () => {} }] : []),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -788,7 +809,7 @@ export function CompositesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="routeflow"
|
id="routeflow"
|
||||||
title="RouteFlow"
|
title="RouteFlow"
|
||||||
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
|
description="Vertical processor node diagram with status coloring, connectors, and optional action menus."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
<RouteFlow
|
<RouteFlow
|
||||||
@@ -802,6 +823,41 @@ export function CompositesSection() {
|
|||||||
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
||||||
]}
|
]}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Change Log Level', onClick: () => {} },
|
||||||
|
{ label: 'Enable Tracing', onClick: () => {} },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 17c. RouteFlow (Multi-Flow) */}
|
||||||
|
<DemoCard
|
||||||
|
id="routeflow-multi"
|
||||||
|
title="RouteFlow (Multi-Flow)"
|
||||||
|
description="Multiple flow segments with labels, showing a main route alongside an exception handler."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
<RouteFlow
|
||||||
|
flows={[
|
||||||
|
{
|
||||||
|
label: 'Main Route',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
|
||||||
|
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
|
||||||
|
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow' },
|
||||||
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'onException(IOException)',
|
||||||
|
variant: 'error',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'log:error-logger', type: 'process', durationMs: 2, status: 'ok' },
|
||||||
|
{ name: 'dead-letter:failed-orders', type: 'to', durationMs: 14, status: 'fail' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import styles from './LayoutSection.module.css'
|
import styles from './LayoutSection.module.css'
|
||||||
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
|
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'
|
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
// ── 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',
|
id: 'app1',
|
||||||
name: 'cameleer-prod',
|
label: 'cameleer-prod',
|
||||||
health: 'live' as const,
|
icon: <StatusDot variant="live" />,
|
||||||
exchangeCount: 14320,
|
badge: '14.3k',
|
||||||
routes: [
|
path: '/apps/app1',
|
||||||
{ id: 'r1', name: 'order-ingest', exchangeCount: 5421 },
|
starrable: true,
|
||||||
{ id: 'r2', name: 'payment-validate', exchangeCount: 3102 },
|
starKey: 'app:app1',
|
||||||
],
|
children: [
|
||||||
agents: [
|
{ id: 'app1/r1', label: 'order-ingest', icon: <ChevronRight size={12} />, badge: '5,421', path: '/apps/app1/r1' },
|
||||||
{ id: 'ag1', name: 'agent-prod-1', status: 'live' as const, tps: 42 },
|
{ id: 'app1/r2', label: 'payment-validate', icon: <ChevronRight size={12} />, badge: '3,102', path: '/apps/app1/r2' },
|
||||||
{ id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: 38 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'app2',
|
id: 'app2',
|
||||||
name: 'cameleer-staging',
|
label: 'cameleer-staging',
|
||||||
health: 'stale' as const,
|
icon: <StatusDot variant="stale" />,
|
||||||
exchangeCount: 871,
|
badge: '871',
|
||||||
routes: [
|
path: '/apps/app2',
|
||||||
{ id: 'r3', name: 'notify-customer', exchangeCount: 2201 },
|
starrable: true,
|
||||||
],
|
starKey: 'app:app2',
|
||||||
agents: [
|
children: [
|
||||||
{ id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: 5 },
|
{ id: 'app2/r3', label: 'notify-customer', icon: <ChevronRight size={12} />, badge: '2,201', path: '/apps/app2/r3' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'app3',
|
id: 'app3',
|
||||||
name: 'cameleer-dev',
|
label: 'cameleer-dev',
|
||||||
health: 'dead' as const,
|
icon: <StatusDot variant="dead" />,
|
||||||
exchangeCount: 42,
|
badge: '42',
|
||||||
routes: [],
|
path: '/apps/app3',
|
||||||
agents: [],
|
starrable: true,
|
||||||
|
starKey: 'app:app3',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -99,10 +102,19 @@ export function LayoutSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
title="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}>
|
<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>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './PrimitivesSection.module.css'
|
import styles from './PrimitivesSection.module.css'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -358,7 +359,7 @@ export function PrimitivesSection() {
|
|||||||
description="Text input with optional leading icon and placeholder."
|
description="Text input with optional leading icon and placeholder."
|
||||||
>
|
>
|
||||||
<Input placeholder="Plain input" />
|
<Input placeholder="Plain input" />
|
||||||
<Input icon="🔍" placeholder="With icon" />
|
<Input icon={<Search size={14} />} placeholder="With icon" />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 15b. InlineEdit */}
|
{/* 15b. InlineEdit */}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import styles from './RouteDetail.module.css'
|
import styles from './RouteDetail.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
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 { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -21,7 +20,6 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
|
|||||||
// Mock data
|
// Mock data
|
||||||
import { routes } from '../../mocks/routes'
|
import { routes } from '../../mocks/routes'
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
@@ -198,11 +196,7 @@ export function RouteDetail() {
|
|||||||
// Not found state
|
// Not found state
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={
|
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
@@ -210,24 +204,19 @@ export function RouteDetail() {
|
|||||||
{ label: id ?? 'Unknown' },
|
{ label: id ?? 'Unknown' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<InfoCallout variant="warning">Route "{id}" not found in mock data.</InfoCallout>
|
<InfoCallout variant="warning">Route "{id}" not found in mock data.</InfoCallout>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusVariant = routeStatusVariant(route.status)
|
const statusVariant = routeStatusVariant(route.status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={
|
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
@@ -337,7 +326,7 @@ export function RouteDetail() {
|
|||||||
expandedContent={(row) =>
|
expandedContent={(row) =>
|
||||||
row.errorMessage ? (
|
row.errorMessage ? (
|
||||||
<div className={styles.inlineError}>
|
<div className={styles.inlineError}>
|
||||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.errorClass}>{row.errorClass}</div>
|
<div className={styles.errorClass}>{row.errorClass}</div>
|
||||||
<div className={styles.errorText}>{row.errorMessage}</div>
|
<div className={styles.errorText}>{row.errorMessage}</div>
|
||||||
@@ -374,6 +363,6 @@ export function RouteDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom'
|
|||||||
import styles from './Routes.module.css'
|
import styles from './Routes.module.css'
|
||||||
|
|
||||||
// Layout
|
// 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'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -33,7 +31,7 @@ import {
|
|||||||
type RouteMetricRow,
|
type RouteMetricRow,
|
||||||
} from '../../mocks/metrics'
|
} from '../../mocks/metrics'
|
||||||
import { routes } from '../../mocks/routes'
|
import { routes } from '../../mocks/routes'
|
||||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||||
|
|
||||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
@@ -410,7 +408,7 @@ export function Routes() {
|
|||||||
// ── Route detail view ───────────────────────────────────────────────────────
|
// ── Route detail view ───────────────────────────────────────────────────────
|
||||||
if (routeId && appId && routeDef) {
|
if (routeId && appId && routeDef) {
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={breadcrumb}
|
breadcrumb={breadcrumb}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
@@ -448,13 +446,13 @@ export function Routes() {
|
|||||||
<RouteFlow nodes={routeFlowNodes} />
|
<RouteFlow nodes={routeFlowNodes} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Top level / Application level view ──────────────────────────────────────
|
// ── Top level / Application level view ──────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={breadcrumb}
|
breadcrumb={breadcrumb}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
@@ -533,6 +531,6 @@ export function Routes() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user