Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df31ec55d8 | ||
|
|
2ed7f8bb0c | ||
|
|
90dee0f43e | ||
|
|
57d60bf2ed | ||
|
|
7e2fce8b14 | ||
|
|
96e5f77a14 | ||
|
|
1c2c00d266 | ||
|
|
70a4db94c1 | ||
|
|
83722aeb7c | ||
|
|
2709d4c164 | ||
|
|
549553c05b | ||
|
|
ac3b69f864 | ||
|
|
a62ff5b064 | ||
|
|
53a9ed015a | ||
|
|
38e42d10bb | ||
|
|
ae420246c8 | ||
|
|
7d9643bd1b | ||
|
|
99ff461d4d | ||
|
|
ce93ba456c | ||
|
|
3fc5fb8267 | ||
|
|
e07afe37f2 | ||
|
|
89453121a0 | ||
|
|
232868b9e7 | ||
|
|
8b32fe3994 | ||
|
|
250cbec7b7 | ||
|
|
dfac0db564 | ||
|
|
bb8e6d9d65 | ||
|
|
fd08d7a552 | ||
|
|
6ea2a29a7c | ||
|
|
51d5d9337a | ||
|
|
cd00a2e0fa | ||
|
|
b443fc787e | ||
|
|
4a6e6dea96 | ||
|
|
638b868649 | ||
|
|
433d0926e6 | ||
|
|
7e545140a2 | ||
|
|
e572df8558 | ||
|
|
a3afe3cb1b | ||
|
|
4841a7ad7c | ||
|
|
32a49690fa | ||
|
|
20f7b2f5aa | ||
|
|
5cb51e65be | ||
|
|
4dcd4aaa27 | ||
|
|
58320b9762 | ||
|
|
c48dffaef2 | ||
|
|
3ef4c5686e | ||
|
|
78e28789a5 | ||
|
|
03ec34bb5c | ||
|
|
2f1df869db | ||
|
|
0cf696cded | ||
|
|
50a1296a9d | ||
|
|
9b8739b5d8 | ||
|
|
ba6028c2ea | ||
|
|
93776944b9 | ||
|
|
9240acddb6 | ||
|
|
912adb1070 | ||
|
|
eeb2713612 | ||
|
|
18bf644040 | ||
|
|
9fa7eb129d | ||
|
|
8cd3c3a99d | ||
|
|
36999941c0 | ||
|
|
5a91875723 | ||
|
|
c401516b2d | ||
|
|
d2c2b92183 | ||
|
|
357e497220 | ||
|
|
1173b3e363 | ||
|
|
7092271fdc | ||
|
|
3561147b42 | ||
|
|
9afe626a58 | ||
|
|
7758962564 | ||
|
|
4e2d5b2b2f | ||
|
|
af48bd2fa0 | ||
|
|
592b60c5fe | ||
|
|
0bb49b83e5 | ||
|
|
8070fdea7c | ||
|
|
7830ac5e0d | ||
|
|
fdccca5378 | ||
|
|
0d4215678a | ||
|
|
28690b2a7a | ||
|
|
5eb807c572 | ||
|
|
f359a2ba3d | ||
|
|
384ee97643 | ||
|
|
a12b374fb2 | ||
|
|
433d582da6 | ||
|
|
2ffc268b44 | ||
|
|
99ae66315b | ||
|
|
26de5ec58f | ||
|
|
d26dc6a8a5 | ||
|
|
c0b1cbdc5b | ||
|
|
d101d883a9 | ||
|
|
2a020c1e15 | ||
|
|
19303eefad | ||
|
|
5fe6321d30 | ||
|
|
90e3de2cdf | ||
|
|
499c86b680 | ||
|
|
63e16d2685 | ||
|
|
19dccb8685 |
@@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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/**"
|
||||||
5
.gitignore
vendored
@@ -1,4 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.claude/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
.gitnexus
|
||||||
|
|||||||
101
AGENTS.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **design-system** (1479 symbols, 2371 relationships, 24 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## When Debugging
|
||||||
|
|
||||||
|
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||||
|
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||||
|
3. `READ gitnexus://repo/design-system/process/{processName}` — trace the full execution flow step by step
|
||||||
|
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||||
|
|
||||||
|
## When Refactoring
|
||||||
|
|
||||||
|
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||||
|
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||||
|
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Tools Quick Reference
|
||||||
|
|
||||||
|
| Tool | When to use | Command |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||||
|
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||||
|
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||||
|
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||||
|
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||||
|
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||||
|
|
||||||
|
## Impact Risk Levels
|
||||||
|
|
||||||
|
| Depth | Meaning | Action |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||||
|
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||||
|
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/design-system/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/design-system/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/design-system/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/design-system/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## Self-Check Before Finishing
|
||||||
|
|
||||||
|
Before completing any code modification task, verify:
|
||||||
|
1. `gitnexus_impact` was run for all modified symbols
|
||||||
|
2. No HIGH/CRITICAL risk warnings were ignored
|
||||||
|
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||||
|
4. All d=1 (WILL BREAK) dependents were updated
|
||||||
|
|
||||||
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
|
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze --embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||||
|
|
||||||
|
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
123
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,121 @@ 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'
|
||||||
|
|
||||||
|
// Brand assets (static files via ./assets/* export)
|
||||||
|
import logo from '@cameleer/design-system/assets/cameleer3-logo.png' // full resolution
|
||||||
|
import logo32 from '@cameleer/design-system/assets/cameleer3-32.png' // 32×32 favicon
|
||||||
|
import logo180 from '@cameleer/design-system/assets/cameleer3-180.png' // Apple touch icon
|
||||||
|
import logo192 from '@cameleer/design-system/assets/cameleer3-192.png' // Android/PWA icon
|
||||||
|
import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA splash, og:image
|
||||||
|
import logoSvg from '@cameleer/design-system/assets/cameleer3-logo.svg' // high-detail SVG logo
|
||||||
|
import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simplified camel SVG
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **design-system** (1479 symbols, 2371 relationships, 24 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## When Debugging
|
||||||
|
|
||||||
|
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||||
|
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||||
|
3. `READ gitnexus://repo/design-system/process/{processName}` — trace the full execution flow step by step
|
||||||
|
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||||
|
|
||||||
|
## When Refactoring
|
||||||
|
|
||||||
|
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||||
|
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||||
|
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Tools Quick Reference
|
||||||
|
|
||||||
|
| Tool | When to use | Command |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||||
|
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||||
|
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||||
|
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||||
|
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||||
|
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||||
|
|
||||||
|
## Impact Risk Levels
|
||||||
|
|
||||||
|
| Depth | Meaning | Action |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||||
|
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||||
|
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/design-system/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/design-system/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/design-system/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/design-system/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## Self-Check Before Finishing
|
||||||
|
|
||||||
|
Before completing any code modification task, verify:
|
||||||
|
1. `gitnexus_impact` was run for all modified symbols
|
||||||
|
2. No HIGH/CRITICAL risk warnings were ignored
|
||||||
|
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||||
|
4. All d=1 (WILL BREAK) dependents were updated
|
||||||
|
|
||||||
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
|
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze --embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||||
|
|
||||||
|
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
|
|||||||
@@ -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**
|
||||||
@@ -52,9 +54,10 @@
|
|||||||
### "I need to display data"
|
### "I need to display data"
|
||||||
- Key metrics → **StatCard** (with optional sparkline/trend)
|
- Key metrics → **StatCard** (with optional sparkline/trend)
|
||||||
- Tabular data → **DataTable** (sortable, paginated)
|
- Tabular data → **DataTable** (sortable, paginated)
|
||||||
- Time series → **LineChart**, **AreaChart**
|
- Time series → **ThemedChart** with `<Line>` or `<Area>`
|
||||||
- Categorical comparison → **BarChart**
|
- Categorical comparison → **ThemedChart** with `<Bar>`
|
||||||
- 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
|
||||||
@@ -118,7 +138,7 @@ FormField wraps any input (Input, Textarea, Select, RadioGroup, etc.)
|
|||||||
### KPI dashboard
|
### KPI dashboard
|
||||||
```
|
```
|
||||||
Row of StatCard components (each with optional Sparkline and trend)
|
Row of StatCard components (each with optional Sparkline and trend)
|
||||||
Below: charts (AreaChart, LineChart, BarChart)
|
Below: charts (ThemedChart with Line, Area, or Bar)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Master/detail management pattern
|
### Master/detail management pattern
|
||||||
@@ -155,11 +175,55 @@ StatCard strip (top, recalculates per scope)
|
|||||||
→ GroupCard grid (2-col for all, full-width for single app)
|
→ GroupCard grid (2-col for all, full-width for single app)
|
||||||
Each GroupCard: header (app name + count) + meta (TPS, routes) + instance rows
|
Each GroupCard: header (app name + count) + meta (TPS, routes) + instance rows
|
||||||
Instance rows: StatusDot + name + Badge + metrics
|
Instance rows: StatusDot + name + Badge + metrics
|
||||||
Single instance: expanded with LineChart panels
|
Single instance: expanded with ThemedChart panels
|
||||||
→ EventFeed (bottom, filtered by scope)
|
→ EventFeed (bottom, filtered by 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 provides a **ThemedChart** wrapper component that applies consistent styling to Recharts charts. Recharts is bundled as a dependency — consumers do not need to install it separately.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
const data = metrics.map(m => ({ time: m.timestamp, cpu: m.value * 100 }))
|
||||||
|
|
||||||
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3" label="Alert" />
|
||||||
|
</ThemedChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ThemedChart Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `data` | `Record<string, any>[]` | required | Flat array of data objects |
|
||||||
|
| `height` | `number` | `200` | Chart height in pixels |
|
||||||
|
| `xDataKey` | `string` | `"time"` | Key for x-axis values |
|
||||||
|
| `xType` | `'number' \| 'category'` | `"category"` | X-axis scale type |
|
||||||
|
| `xTickFormatter` | `(value: any) => string` | — | Custom x-axis label formatter |
|
||||||
|
| `yTickFormatter` | `(value: any) => string` | — | Custom y-axis label formatter |
|
||||||
|
| `yLabel` | `string` | — | Y-axis label text |
|
||||||
|
| `children` | `ReactNode` | required | Recharts elements (Line, Area, Bar, etc.) |
|
||||||
|
| `className` | `string` | — | Container CSS class |
|
||||||
|
|
||||||
|
### Available Recharts Re-exports
|
||||||
|
|
||||||
|
`Line`, `Area`, `Bar`, `ReferenceLine`, `ReferenceArea`, `Legend`, `Brush`
|
||||||
|
|
||||||
|
For chart types not covered (treemap, radar, pie, sankey), import from `recharts` directly and use `rechartsTheme` for consistent styling.
|
||||||
|
|
||||||
|
### Theme Utilities
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
||||||
|
| `rechartsTheme` | Pre-configured prop objects for Recharts sub-components |
|
||||||
|
|
||||||
## Component Index
|
## Component Index
|
||||||
|
|
||||||
| Component | Layer | When to use |
|
| Component | Layer | When to use |
|
||||||
@@ -167,11 +231,9 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| Accordion | composite | Multiple collapsible sections, single or multi-open mode |
|
| Accordion | composite | Multiple collapsible sections, single or multi-open mode |
|
||||||
| Alert | primitive | Page-level attention banner with variant colors |
|
| Alert | primitive | Page-level attention banner with variant colors |
|
||||||
| AlertDialog | composite | Confirmation dialog for destructive/important actions |
|
| AlertDialog | composite | Confirmation dialog for destructive/important actions |
|
||||||
| AreaChart | composite | Time series visualization with filled area |
|
|
||||||
| Avatar | primitive | User representation with initials and color |
|
| Avatar | primitive | User representation with initials and color |
|
||||||
| AvatarGroup | composite | Stacked overlapping avatars with overflow count |
|
| AvatarGroup | composite | Stacked overlapping avatars with overflow count |
|
||||||
| Badge | primitive | Labeled status indicator with semantic colors |
|
| Badge | primitive | Labeled status indicator with semantic colors |
|
||||||
| BarChart | composite | Categorical data comparison, optional stacking |
|
|
||||||
| Breadcrumb | composite | Navigation path showing current location |
|
| Breadcrumb | composite | Navigation path showing current location |
|
||||||
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
||||||
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
||||||
@@ -179,7 +241,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 |
|
||||||
@@ -199,7 +261,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
| KeyboardHint | primitive | Keyboard shortcut display |
|
||||||
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
||||||
| Label | primitive | Form label with optional required asterisk |
|
| Label | primitive | Form label with optional required asterisk |
|
||||||
| LineChart | composite | Time series line visualization |
|
| ThemedChart | composite | Recharts wrapper with themed axes, grid, and tooltip |
|
||||||
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
||||||
| MenuItem | composite | Sidebar navigation item with health/count |
|
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||||
| Modal | composite | Generic dialog overlay with backdrop |
|
| Modal | composite | Generic dialog overlay with backdrop |
|
||||||
@@ -207,8 +269,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,8 +298,10 @@ 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. |
|
||||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
|
||||||
|
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
|
||||||
|
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
|
||||||
|
|
||||||
## Import Paths
|
## Import Paths
|
||||||
|
|
||||||
@@ -248,6 +312,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'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -261,6 +329,35 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
|
|||||||
|
|
||||||
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||||
|
|
||||||
|
## Brand Assets
|
||||||
|
|
||||||
|
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
|
||||||
|
|
||||||
|
| File | Size | Use case |
|
||||||
|
|------|------|----------|
|
||||||
|
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
|
||||||
|
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
|
||||||
|
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
|
||||||
|
| `cameleer3-48.png` | 48×48 | Windows taskbar |
|
||||||
|
| `cameleer3-180.png` | 180×180 | Apple touch icon |
|
||||||
|
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
|
||||||
|
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
|
||||||
|
| `cameleer3-logo.svg` | Vector | High-detail SVG logo (traced from PNG, transparent) |
|
||||||
|
| `camel-logo.svg` | Vector | Simplified camel SVG logo |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import logo from '@cameleer/design-system/assets/cameleer3-512.png'
|
||||||
|
<img src={logo} alt="Cameleer3" />
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Favicons in index.html -->
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/cameleer3-32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/cameleer3-180.png">
|
||||||
|
```
|
||||||
|
|
||||||
## Styling Rules
|
## Styling Rules
|
||||||
|
|
||||||
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
||||||
|
|||||||
3
assets/camel-logo.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/cameleer-16.png
Normal file
|
After Width: | Height: | Size: 941 B |
BIN
assets/cameleer-180.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/cameleer-192.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/cameleer-32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
assets/cameleer-48.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
assets/cameleer-512.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
assets/cameleer-logo.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
112
assets/cameleer-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
1609
docs/superpowers/plans/2026-04-02-composable-sidebar.md
Normal file
845
docs/superpowers/plans/2026-04-12-recharts-migration.md
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
# Recharts Migration Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the design system's hand-rolled SVG chart components with a single `ThemedChart` wrapper around Recharts, using Recharts-native data format.
|
||||||
|
|
||||||
|
**Architecture:** Add Recharts as a DS dependency. Create `ThemedChart` component that renders `ResponsiveContainer` + `ComposedChart` with pre-themed grid/axes/tooltip. Consumers compose Recharts elements (`<Line>`, `<Area>`, `<Bar>`, `<ReferenceLine>`) as children. Delete old `LineChart/`, `AreaChart/`, `BarChart/`, `_chart-utils.ts`. Migrate the server UI's `AgentInstance.tsx` to the new API.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Recharts, CSS Modules, Vitest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Design System (`C:\Users\Hendrik\Documents\projects\design-system`):**
|
||||||
|
|
||||||
|
| Action | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ThemedChart.tsx` | Wrapper component: ResponsiveContainer + ComposedChart + themed axes/grid/tooltip |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ChartTooltip.tsx` | Custom tooltip with timestamp header + series values |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ChartTooltip.module.css` | Tooltip styles using DS tokens |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ThemedChart.test.tsx` | Render tests for ThemedChart |
|
||||||
|
| Modify | `src/design-system/utils/rechartsTheme.ts` | Move `CHART_COLORS` definition here (was in `_chart-utils.ts`) |
|
||||||
|
| Modify | `src/design-system/composites/index.ts` | Remove old chart exports, add ThemedChart + Recharts re-exports |
|
||||||
|
| Modify | `COMPONENT_GUIDE.md` | Update charting strategy section |
|
||||||
|
| Modify | `package.json` | Add `recharts` dependency, bump version |
|
||||||
|
| Delete | `src/design-system/composites/LineChart/` | Old hand-rolled SVG line chart |
|
||||||
|
| Delete | `src/design-system/composites/AreaChart/` | Old hand-rolled SVG area chart |
|
||||||
|
| Delete | `src/design-system/composites/BarChart/` | Old hand-rolled SVG bar chart |
|
||||||
|
| Delete | `src/design-system/composites/_chart-utils.ts` | Old chart utilities |
|
||||||
|
|
||||||
|
**Server UI (`C:\Users\Hendrik\Documents\projects\cameleer3-server`):**
|
||||||
|
|
||||||
|
| Action | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Modify | `ui/src/pages/AgentInstance/AgentInstance.tsx` | Migrate 6 charts to ThemedChart + Recharts children |
|
||||||
|
| Modify | `ui/package.json` | Update `@cameleer/design-system` to new version |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Recharts Dependency and Move CHART_COLORS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `src/design-system/utils/rechartsTheme.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install recharts**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\design-system
|
||||||
|
npm install recharts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move CHART_COLORS into rechartsTheme.ts**
|
||||||
|
|
||||||
|
Replace the entire file `src/design-system/utils/rechartsTheme.ts` with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const CHART_COLORS = [
|
||||||
|
'var(--chart-1)',
|
||||||
|
'var(--chart-2)',
|
||||||
|
'var(--chart-3)',
|
||||||
|
'var(--chart-4)',
|
||||||
|
'var(--chart-5)',
|
||||||
|
'var(--chart-6)',
|
||||||
|
'var(--chart-7)',
|
||||||
|
'var(--chart-8)',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-configured Recharts prop objects that match the design system's
|
||||||
|
* chart styling. Used internally by ThemedChart and available for
|
||||||
|
* consumers composing Recharts directly.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds. The old chart components still import `CHART_COLORS` from `_chart-utils.ts` which still exists — they'll be deleted in Task 4.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json src/design-system/utils/rechartsTheme.ts
|
||||||
|
git commit -m "chore: add recharts dependency, move CHART_COLORS to rechartsTheme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create ChartTooltip Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ChartTooltip.tsx`
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ChartTooltip.module.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create tooltip CSS**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ChartTooltip.module.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.tooltip {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create ChartTooltip component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ChartTooltip.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { TooltipProps } from 'recharts'
|
||||||
|
import styles from './ChartTooltip.module.css'
|
||||||
|
|
||||||
|
function formatValue(val: number): string {
|
||||||
|
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
||||||
|
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
|
||||||
|
if (Number.isInteger(val)) return String(val)
|
||||||
|
return val.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(val: unknown): string | null {
|
||||||
|
if (val == null) return null
|
||||||
|
const str = String(val)
|
||||||
|
const ms = typeof val === 'number' && val > 1e12 ? val
|
||||||
|
: typeof val === 'number' && val > 1e9 ? val * 1000
|
||||||
|
: Date.parse(str)
|
||||||
|
if (isNaN(ms)) return str
|
||||||
|
return new Date(ms).toLocaleString([], {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label }: TooltipProps<number, string>) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
|
const timeLabel = formatTimestamp(label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tooltip}>
|
||||||
|
{timeLabel && <div className={styles.time}>{timeLabel}</div>}
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.dataKey as string} className={styles.row}>
|
||||||
|
<span className={styles.dot} style={{ background: entry.color }} />
|
||||||
|
<span className={styles.label}>{entry.name}:</span>
|
||||||
|
<span className={styles.value}>{formatValue(entry.value as number)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/ThemedChart/
|
||||||
|
git commit -m "feat: add ChartTooltip component for ThemedChart"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create ThemedChart Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ThemedChart.tsx`
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ThemedChart.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create ThemedChart component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ThemedChart.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
ComposedChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
} from 'recharts'
|
||||||
|
import { rechartsTheme } from '../../utils/rechartsTheme'
|
||||||
|
import { ChartTooltip } from './ChartTooltip'
|
||||||
|
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number
|
||||||
|
xDataKey?: string
|
||||||
|
xType?: 'number' | 'category'
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedChart({
|
||||||
|
data,
|
||||||
|
height = 200,
|
||||||
|
xDataKey = 'time',
|
||||||
|
xType = 'category',
|
||||||
|
xTickFormatter,
|
||||||
|
yTickFormatter,
|
||||||
|
yLabel,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ThemedChartProps) {
|
||||||
|
if (!data.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ width: '100%', height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey={xDataKey}
|
||||||
|
type={xType}
|
||||||
|
{...rechartsTheme.xAxis}
|
||||||
|
tickFormatter={xTickFormatter}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
{...rechartsTheme.yAxis}
|
||||||
|
tickFormatter={yTickFormatter}
|
||||||
|
label={yLabel ? {
|
||||||
|
value: yLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
style: { fontSize: 11, fill: 'var(--text-muted)' },
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltip />} cursor={rechartsTheme.tooltip.cursor} />
|
||||||
|
{children}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write test**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ThemedChart.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ThemedChart } from './ThemedChart'
|
||||||
|
import { Line } from 'recharts'
|
||||||
|
|
||||||
|
// Recharts uses ResizeObserver internally
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = ResizeObserverMock as any
|
||||||
|
|
||||||
|
describe('ThemedChart', () => {
|
||||||
|
it('renders nothing when data is empty', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={[]}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a chart container when data is provided', () => {
|
||||||
|
const data = [
|
||||||
|
{ time: '10:00', value: 10 },
|
||||||
|
{ time: '10:01', value: 20 },
|
||||||
|
]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} height={160}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.recharts-responsive-container')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const data = [{ time: '10:00', value: 5 }]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} className="my-chart">
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.my-chart')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/composites/ThemedChart/ThemedChart.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 3 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify lib build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/ThemedChart/
|
||||||
|
git commit -m "feat: add ThemedChart wrapper component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update Barrel Exports and Delete Old Charts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/composites/index.ts`
|
||||||
|
- Delete: `src/design-system/composites/LineChart/`
|
||||||
|
- Delete: `src/design-system/composites/AreaChart/`
|
||||||
|
- Delete: `src/design-system/composites/BarChart/`
|
||||||
|
- Delete: `src/design-system/composites/_chart-utils.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update composites/index.ts**
|
||||||
|
|
||||||
|
Remove these lines:
|
||||||
|
```
|
||||||
|
export { AreaChart } from './AreaChart/AreaChart'
|
||||||
|
export { BarChart } from './BarChart/BarChart'
|
||||||
|
export { LineChart } from './LineChart/LineChart'
|
||||||
|
export { CHART_COLORS } from './_chart-utils'
|
||||||
|
export type { ChartSeries, DataPoint } from './_chart-utils'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add in their place:
|
||||||
|
```tsx
|
||||||
|
// Charts — ThemedChart wrapper + Recharts re-exports
|
||||||
|
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||||
|
export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
|
||||||
|
export {
|
||||||
|
Line, Area, Bar,
|
||||||
|
ReferenceLine, ReferenceArea,
|
||||||
|
Legend, Brush,
|
||||||
|
} from 'recharts'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the rechartsTheme re-export from main index.ts**
|
||||||
|
|
||||||
|
In `src/design-system/index.ts`, remove this line (it's now re-exported via composites):
|
||||||
|
```
|
||||||
|
export * from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with a targeted export that avoids double-exporting `CHART_COLORS`:
|
||||||
|
```tsx
|
||||||
|
export { rechartsTheme } from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — actually `composites/index.ts` already re-exports both `CHART_COLORS` and `rechartsTheme`. And `index.ts` does `export * from './composites'`. So the main `index.ts` line `export * from './utils/rechartsTheme'` would cause a duplicate export of both symbols. Remove it entirely:
|
||||||
|
|
||||||
|
Delete this line from `src/design-system/index.ts`:
|
||||||
|
```
|
||||||
|
export * from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete old chart directories and utilities**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf src/design-system/composites/LineChart
|
||||||
|
rm -rf src/design-system/composites/AreaChart
|
||||||
|
rm -rf src/design-system/composites/BarChart
|
||||||
|
rm src/design-system/composites/_chart-utils.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify lib build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds. The old components are gone, ThemedChart and Recharts re-exports are the new public API.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass. No test files existed for the deleted components.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat!: replace custom chart components with ThemedChart + Recharts
|
||||||
|
|
||||||
|
BREAKING: LineChart, AreaChart, BarChart, ChartSeries, DataPoint removed.
|
||||||
|
Use ThemedChart with Recharts children (Line, Area, Bar, etc.) instead."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update COMPONENT_GUIDE.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `COMPONENT_GUIDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the charting strategy section**
|
||||||
|
|
||||||
|
Find the section starting with `## Charting Strategy` (around line 183) and replace through line 228 with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Charting Strategy
|
||||||
|
|
||||||
|
The design system provides a **ThemedChart** wrapper component that applies consistent styling to Recharts charts. Recharts is bundled as a dependency — consumers do not need to install it separately.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
const data = metrics.map(m => ({ time: m.timestamp, cpu: m.value * 100 }))
|
||||||
|
|
||||||
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3" label="Alert" />
|
||||||
|
</ThemedChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ThemedChart Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `data` | `Record<string, any>[]` | required | Flat array of data objects |
|
||||||
|
| `height` | `number` | `200` | Chart height in pixels |
|
||||||
|
| `xDataKey` | `string` | `"time"` | Key for x-axis values |
|
||||||
|
| `xType` | `'number' \| 'category'` | `"category"` | X-axis scale type |
|
||||||
|
| `xTickFormatter` | `(value: any) => string` | — | Custom x-axis label formatter |
|
||||||
|
| `yTickFormatter` | `(value: any) => string` | — | Custom y-axis label formatter |
|
||||||
|
| `yLabel` | `string` | — | Y-axis label text |
|
||||||
|
| `children` | `ReactNode` | required | Recharts elements (Line, Area, Bar, etc.) |
|
||||||
|
| `className` | `string` | — | Container CSS class |
|
||||||
|
|
||||||
|
### Available Recharts Re-exports
|
||||||
|
|
||||||
|
`Line`, `Area`, `Bar`, `ReferenceLine`, `ReferenceArea`, `Legend`, `Brush`
|
||||||
|
|
||||||
|
For chart types not covered (treemap, radar, pie, sankey), import from `recharts` directly and use `rechartsTheme` for consistent styling.
|
||||||
|
|
||||||
|
### Theme Utilities
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
||||||
|
| `rechartsTheme` | Pre-configured prop objects for Recharts sub-components |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the component index table**
|
||||||
|
|
||||||
|
Find the rows for `AreaChart`, `BarChart`, `LineChart` in the component index table and replace all three with:
|
||||||
|
|
||||||
|
```
|
||||||
|
| ThemedChart | composite | Recharts wrapper with themed axes, grid, and tooltip |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the decision tree**
|
||||||
|
|
||||||
|
Find lines 57-60 (the chart decision tree entries):
|
||||||
|
```
|
||||||
|
- Time series → **LineChart**, **AreaChart**
|
||||||
|
- Categorical comparison → **BarChart**
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```
|
||||||
|
- Time series → **ThemedChart** with `<Line>` or `<Area>`
|
||||||
|
- Categorical comparison → **ThemedChart** with `<Bar>`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add COMPONENT_GUIDE.md
|
||||||
|
git commit -m "docs: update COMPONENT_GUIDE for ThemedChart migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Publish Design System
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` (version bump)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump version**
|
||||||
|
|
||||||
|
In `package.json`, change `"version"` to `"0.1.47"`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit and tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json
|
||||||
|
git commit -m "chore: bump version to 0.1.47"
|
||||||
|
git tag v0.1.47
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wait for CI to publish**
|
||||||
|
|
||||||
|
Wait for the Gitea CI pipeline to build and publish `@cameleer/design-system@0.1.47` to the npm registry. Verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm view @cameleer/design-system@0.1.47 version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Migrate Server UI AgentInstance Charts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:\Users\Hendrik\Documents\projects\cameleer3-server\ui\src\pages\AgentInstance\AgentInstance.tsx`
|
||||||
|
- Modify: `C:\Users\Hendrik\Documents\projects\cameleer3-server\ui\package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update design system dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server\ui
|
||||||
|
npm install @cameleer/design-system@0.1.47
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update imports in AgentInstance.tsx**
|
||||||
|
|
||||||
|
Replace the chart-related imports:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
|
||||||
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||||
|
LogViewer, ButtonGroup, useGlobalFilters,
|
||||||
|
} from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge,
|
||||||
|
ThemedChart, Line, Area, ReferenceLine, CHART_COLORS,
|
||||||
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||||
|
LogViewer, ButtonGroup, useGlobalFilters,
|
||||||
|
} from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace data prep — JVM metrics**
|
||||||
|
|
||||||
|
Replace the 4 JVM series useMemo blocks (cpuSeries, heapSeries, threadSeries, gcSeries) with flat data builders:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const formatTime = (t: string) =>
|
||||||
|
new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
// JVM chart data — merge all metrics into flat objects by time bucket
|
||||||
|
const cpuData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['process.cpu.usage.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, cpu: p.value * 100 }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const heapData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.memory.used.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, heap: p.value / (1024 * 1024) }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const threadData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.threads.live.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, threads: p.value }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const gcData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.gc.pause.total_time'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, gc: p.value }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace data prep — throughput and error**
|
||||||
|
|
||||||
|
Replace the throughputSeries and errorSeries useMemo blocks:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const throughputData = useMemo(() => {
|
||||||
|
if (!chartData.length) return [];
|
||||||
|
return chartData.map((d: any) => ({ time: d.date.toISOString(), throughput: d.throughput }));
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
const errorData = useMemo(() => {
|
||||||
|
if (!chartData.length) return [];
|
||||||
|
return chartData.map((d: any) => ({ time: d.date.toISOString(), errorPct: d.errorPct }));
|
||||||
|
}, [chartData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Replace chart JSX — CPU Usage**
|
||||||
|
|
||||||
|
Replace the CPU chart card content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{cpuData.length ? (
|
||||||
|
<ThemedChart data={cpuData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="%">
|
||||||
|
<Area dataKey="cpu" name="CPU %" stroke={CHART_COLORS[0]}
|
||||||
|
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3"
|
||||||
|
label={{ value: 'Alert', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No CPU metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Replace chart JSX — Memory (Heap)**
|
||||||
|
|
||||||
|
Replace the heap chart card content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{heapData.length ? (
|
||||||
|
<ThemedChart data={heapData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="MB">
|
||||||
|
<Area dataKey="heap" name="Heap MB" stroke={CHART_COLORS[0]}
|
||||||
|
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
{heapMax != null && (
|
||||||
|
<ReferenceLine y={heapMax / (1024 * 1024)} stroke="var(--error)" strokeDasharray="5 3"
|
||||||
|
label={{ value: 'Max Heap', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
|
||||||
|
)}
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No heap metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Replace chart JSX — Throughput**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{throughputData.length ? (
|
||||||
|
<ThemedChart data={throughputData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="msg/s">
|
||||||
|
<Line dataKey="throughput" name="msg/s" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No throughput data in range" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Replace chart JSX — Error Rate**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{errorData.length ? (
|
||||||
|
<ThemedChart data={errorData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="%">
|
||||||
|
<Line dataKey="errorPct" name="Error %" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No error data in range" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Replace chart JSX — Thread Count**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{threadData.length ? (
|
||||||
|
<ThemedChart data={threadData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="threads">
|
||||||
|
<Line dataKey="threads" name="Threads" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No thread metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Replace chart JSX — GC Pauses**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{gcData.length ? (
|
||||||
|
<ThemedChart data={gcData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="ms">
|
||||||
|
<Area dataKey="gc" name="GC ms" stroke={CHART_COLORS[1]}
|
||||||
|
fill={CHART_COLORS[1]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No GC metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 11: Update the Thread Count meta display**
|
||||||
|
|
||||||
|
The thread count meta currently reads from `threadSeries`. Update to read from `threadData`:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```tsx
|
||||||
|
{threadSeries
|
||||||
|
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
|
||||||
|
: ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
{threadData.length
|
||||||
|
? `${threadData[threadData.length - 1].threads.toFixed(0)} active`
|
||||||
|
: ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 12: Build server UI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server\ui
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 13: Commit and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server
|
||||||
|
git add ui/
|
||||||
|
git commit -m "feat: migrate agent charts to ThemedChart + Recharts
|
||||||
|
|
||||||
|
Replace custom LineChart/AreaChart/BarChart usage with ThemedChart
|
||||||
|
wrapper. Data format changed from ChartSeries[] to Recharts-native
|
||||||
|
flat objects. Uses DS v0.1.47."
|
||||||
|
git push
|
||||||
|
```
|
||||||
541
docs/superpowers/plans/2026-04-15-sidebar-section-layout.md
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
# Sidebar Section Layout Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add `position` and `maxHeight` props to `Sidebar.Section` so sections can stack at the top or bottom of the sidebar with optional scrollable content areas, and style all scrollbars to match the dark sidebar aesthetic.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing flexbox column layout inside `SidebarRoot` by partitioning section children into top/bottom groups with a flex spacer between them. Each group wrapper scrolls independently when the viewport is short. `SidebarSection` gets a content wrapper div that accepts an inline `maxHeight` and scrolls its children. Custom thin scrollbar styles are applied via CSS.
|
||||||
|
|
||||||
|
**Tech Stack:** React, CSS Modules, Vitest + React Testing Library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/design-system/layout/Sidebar/Sidebar.tsx` | Modify | Add `position` and `maxHeight` props to `SidebarSectionProps`, wrap children in `.sectionContent` div, partition children in `SidebarRoot` into top/bottom groups |
|
||||||
|
| `src/design-system/layout/Sidebar/Sidebar.module.css` | Modify | Add `.sectionGroup`, `.sectionSpacer`, `.sectionContent` classes, custom scrollbar styles |
|
||||||
|
| `src/design-system/layout/Sidebar/Sidebar.test.tsx` | Modify | Add tests for position partitioning, maxHeight content wrapper, spacer rendering, collapsed behavior |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add CSS classes for section groups, spacer, content wrapper, and scrollbars
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/layout/Sidebar/Sidebar.module.css:392` (before the bottom links section)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `.sectionGroup`, `.sectionSpacer`, `.sectionContent`, and scrollbar styles**
|
||||||
|
|
||||||
|
Insert the following block before the `/* ── Bottom links */` comment at line 392:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Section groups (top/bottom positioning) ───────────────────────────── */
|
||||||
|
|
||||||
|
.sectionGroup {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionGroup::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionGroup::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionGroup::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionGroup::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSpacer {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section content (scrollable maxHeight) ────────────────────────────── */
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/layout/Sidebar/Sidebar.module.css
|
||||||
|
git commit -m "style: add section group, spacer, content, and scrollbar CSS classes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add `maxHeight` prop and content wrapper to `SidebarSection`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:21-29` (SidebarSectionProps)
|
||||||
|
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:78-131` (SidebarSection component)
|
||||||
|
- Test: `src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add the following tests to the end of the `describe` block in `Sidebar.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 17. renders sectionContent wrapper with maxHeight when open
|
||||||
|
it('renders section content wrapper with maxHeight style when open', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Apps"
|
||||||
|
open
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
maxHeight="200px"
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
|
expect(contentWrapper).toBeInTheDocument()
|
||||||
|
expect(contentWrapper).toHaveStyle({ maxHeight: '200px' })
|
||||||
|
expect(screen.getByText('child')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 18. renders sectionContent wrapper without maxHeight when not provided
|
||||||
|
it('renders section content wrapper without inline maxHeight when maxHeight is not provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Apps"
|
||||||
|
open
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
|
expect(contentWrapper).toBeInTheDocument()
|
||||||
|
expect(contentWrapper).not.toHaveStyle({ maxHeight: '200px' })
|
||||||
|
expect(screen.getByText('child')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 19. does not render sectionContent wrapper when section is closed
|
||||||
|
it('does not render section content wrapper when section is closed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Apps"
|
||||||
|
open={false}
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
maxHeight="200px"
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
|
expect(contentWrapper).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
Expected: FAIL — `maxHeight` prop is not recognized, no `.sectionContent` wrapper exists.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `position` and `maxHeight` to `SidebarSectionProps`**
|
||||||
|
|
||||||
|
Update the interface at line 21:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SidebarSectionProps {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
active?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
position?: 'top' | 'bottom'
|
||||||
|
maxHeight?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `SidebarSection` to destructure new props and wrap children**
|
||||||
|
|
||||||
|
Update the function signature at line 78 to destructure the new props (they are accepted but `position` is only used by `SidebarRoot`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SidebarSection({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
position: _position,
|
||||||
|
maxHeight,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace `{open && children}` (line 128) with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={styles.sectionContent}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/layout/Sidebar/Sidebar.tsx src/design-system/layout/Sidebar/Sidebar.test.tsx
|
||||||
|
git commit -m "feat: add maxHeight prop with sectionContent wrapper to SidebarSection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Partition children in `SidebarRoot` into top/bottom groups
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:167-244` (SidebarRoot component)
|
||||||
|
- Test: `src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add the following tests to the end of the `describe` block in `Sidebar.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 20. renders top sections in sectionGroup wrapper
|
||||||
|
it('renders sections in top group wrapper by default', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const topGroup = container.querySelector('.sectionGroup')
|
||||||
|
expect(topGroup).toBeInTheDocument()
|
||||||
|
expect(topGroup!.textContent).toContain('Apps')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 21. renders bottom sections in separate group with spacer
|
||||||
|
it('renders bottom sections in a separate group with spacer between', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Routes" open onToggle={vi.fn()} position="bottom">
|
||||||
|
<div>routes content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = container.querySelectorAll('.sectionGroup')
|
||||||
|
expect(groups).toHaveLength(2)
|
||||||
|
expect(groups[0].textContent).toContain('Apps')
|
||||||
|
expect(groups[1].textContent).toContain('Routes')
|
||||||
|
|
||||||
|
const spacer = container.querySelector('.sectionSpacer')
|
||||||
|
expect(spacer).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 22. does not render spacer when no bottom sections
|
||||||
|
it('does not render spacer when all sections are top', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Agents" open onToggle={vi.fn()}>
|
||||||
|
<div>agents content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const spacer = container.querySelector('.sectionSpacer')
|
||||||
|
expect(spacer).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 23. collapsed sidebar renders bottom sections in bottom group
|
||||||
|
it('renders bottom sections in bottom group when sidebar is collapsed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar collapsed onCollapseToggle={vi.fn()}>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open={false} onToggle={vi.fn()}>
|
||||||
|
<div>apps</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Routes" open={false} onToggle={vi.fn()} position="bottom">
|
||||||
|
<div>routes</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = container.querySelectorAll('.sectionGroup')
|
||||||
|
expect(groups).toHaveLength(2)
|
||||||
|
|
||||||
|
// Bottom group should contain the Routes rail item
|
||||||
|
const bottomGroup = groups[1]
|
||||||
|
expect(bottomGroup.querySelector('[title="Routes"]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
Expected: FAIL — no `.sectionGroup` wrappers exist yet, sections render directly in `{rest}`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite the child partitioning logic in `SidebarRoot`**
|
||||||
|
|
||||||
|
Replace the entire IIFE inside the `<aside>` (lines 198–240) with the following:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{(() => {
|
||||||
|
const childArray = Children.toArray(children)
|
||||||
|
|
||||||
|
// Extract header
|
||||||
|
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
|
||||||
|
|
||||||
|
// Extract footer
|
||||||
|
const footerIdx = rest.findIndex(
|
||||||
|
(child) => isValidElement(child) && child.type === SidebarFooter,
|
||||||
|
)
|
||||||
|
const footer = footerIdx >= 0 ? rest[footerIdx] : null
|
||||||
|
const sections = footerIdx >= 0
|
||||||
|
? [...rest.slice(0, footerIdx), ...rest.slice(footerIdx + 1)]
|
||||||
|
: rest
|
||||||
|
|
||||||
|
// Partition sections into top/bottom by position prop
|
||||||
|
const topSections: typeof sections = []
|
||||||
|
const bottomSections: typeof sections = []
|
||||||
|
for (const child of sections) {
|
||||||
|
if (
|
||||||
|
isValidElement<SidebarSectionProps>(child) &&
|
||||||
|
child.props.position === 'bottom'
|
||||||
|
) {
|
||||||
|
bottomSections.push(child)
|
||||||
|
} else {
|
||||||
|
topSections.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBottomSections = bottomSections.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
{onSearchChange && !collapsed && (
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<div className={styles.searchInner}>
|
||||||
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
|
<Search size={12} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className={styles.searchInput}
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter..."
|
||||||
|
value={searchValue ?? ''}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => onSearchChange('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.sectionGroup}>
|
||||||
|
{topSections}
|
||||||
|
</div>
|
||||||
|
{hasBottomSections && <div className={styles.sectionSpacer} />}
|
||||||
|
{hasBottomSections && (
|
||||||
|
<div className={styles.sectionGroup}>
|
||||||
|
{bottomSections}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full test suite to check for regressions**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/layout/Sidebar/Sidebar.tsx src/design-system/layout/Sidebar/Sidebar.test.tsx
|
||||||
|
git commit -m "feat: partition sidebar sections into top/bottom groups with spacer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update `LayoutShell` to use `position="bottom"` on Routes and Starred sections
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/layout/LayoutShell.tsx:378-429` (the Routes and Starred section declarations)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `position="bottom"` to the Routes section**
|
||||||
|
|
||||||
|
In `LayoutShell.tsx`, find the Routes `<Sidebar.Section>` (around line 378) and add `position="bottom"`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Section
|
||||||
|
label="Routes"
|
||||||
|
icon={<GitBranch size={14} />}
|
||||||
|
open={!routesCollapsed}
|
||||||
|
onToggle={toggleRoutesCollapsed}
|
||||||
|
active={location.pathname.startsWith('/routes')}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `position="bottom"` to the Starred section**
|
||||||
|
|
||||||
|
Find the Starred `<Sidebar.Section>` (around line 396) and add `position="bottom"`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{hasStarred && (
|
||||||
|
<Sidebar.Section
|
||||||
|
label="★ Starred"
|
||||||
|
icon={<span />}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
active={false}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/layout/LayoutShell.tsx
|
||||||
|
git commit -m "feat: position Routes and Starred sections at bottom of sidebar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Visual verification in the browser
|
||||||
|
|
||||||
|
**Files:** None (manual verification)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start the dev server**
|
||||||
|
|
||||||
|
Run: `npm run dev`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify top/bottom layout**
|
||||||
|
|
||||||
|
Open the app in a browser. Confirm:
|
||||||
|
- Applications and Agents sections are at the top
|
||||||
|
- Routes and Starred sections are at the bottom, above the footer
|
||||||
|
- A flexible gap separates the two groups
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify scrollbar styling**
|
||||||
|
|
||||||
|
If a section has enough content to overflow (or temporarily add `maxHeight="100px"` to the Applications section in `LayoutShell.tsx`), confirm:
|
||||||
|
- Content scrolls within the section
|
||||||
|
- Scrollbar is thin (4px), with a muted thumb on a transparent track
|
||||||
|
- Scrollbar thumb brightens on hover
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify collapsed sidebar**
|
||||||
|
|
||||||
|
Collapse the sidebar and confirm:
|
||||||
|
- Top section icons stay at the top
|
||||||
|
- Bottom section icons cluster near the footer
|
||||||
|
- Spacer separates the two groups in the rail
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify short viewport**
|
||||||
|
|
||||||
|
Resize the browser window very short. Confirm:
|
||||||
|
- Both top and bottom groups scroll independently
|
||||||
|
- No content is permanently clipped or inaccessible
|
||||||
|
|
||||||
|
- [ ] **Step 6: Remove any temporary `maxHeight` props added for testing**
|
||||||
|
|
||||||
|
If you added `maxHeight="100px"` to Applications in Step 3, remove it now.
|
||||||
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.
|
||||||
121
docs/superpowers/specs/2026-04-12-recharts-migration-design.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Recharts Migration Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the design system's hand-rolled SVG chart components (LineChart, AreaChart, BarChart) with Recharts-based implementations. The current custom charts have responsiveness issues (preserveAspectRatio distorting text), limited tooltip/axis formatting, and growing maintenance burden.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Add Recharts as a DS dependency. Replace the three custom chart components and `_chart-utils.ts` with a single `ThemedChart` wrapper component. Consumers compose Recharts elements (`<Line>`, `<Area>`, `<Bar>`, etc.) as children inside `<ThemedChart>`. The DS re-exports Recharts components so consumers don't need to add Recharts as a separate dependency.
|
||||||
|
|
||||||
|
Sparkline stays hand-rolled SVG (no axes, no tooltips — Recharts is overkill).
|
||||||
|
|
||||||
|
### What gets removed
|
||||||
|
|
||||||
|
- `composites/LineChart/` directory (LineChart.tsx, LineChart.module.css)
|
||||||
|
- `composites/AreaChart/` directory (AreaChart.tsx, AreaChart.module.css)
|
||||||
|
- `composites/BarChart/` directory (BarChart.tsx, BarChart.module.css)
|
||||||
|
- `composites/_chart-utils.ts`
|
||||||
|
- `ChartSeries` and `DataPoint` type exports
|
||||||
|
|
||||||
|
### What gets added
|
||||||
|
|
||||||
|
- `composites/ThemedChart/ThemedChart.tsx` — wrapper component
|
||||||
|
- `composites/ThemedChart/ChartTooltip.tsx` — internal themed tooltip
|
||||||
|
- `composites/ThemedChart/ThemedChart.module.css`
|
||||||
|
- Recharts re-exports from the DS barrel
|
||||||
|
|
||||||
|
### What stays unchanged
|
||||||
|
|
||||||
|
- `utils/rechartsTheme.ts` (now also used internally by ThemedChart)
|
||||||
|
- `CHART_COLORS`
|
||||||
|
- `primitives/Sparkline/`
|
||||||
|
|
||||||
|
## Data Format
|
||||||
|
|
||||||
|
Consumers use Recharts-native flat data format instead of the current `ChartSeries[]`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (custom)
|
||||||
|
const series = [
|
||||||
|
{ label: 'CPU %', data: pts.map(p => ({ x: new Date(p.time), y: p.value * 100 })) }
|
||||||
|
]
|
||||||
|
<AreaChart series={series} height={160} yLabel="%" />
|
||||||
|
|
||||||
|
// After (Recharts-native)
|
||||||
|
const data = pts.map(p => ({ time: p.time, cpu: p.value * 100 }))
|
||||||
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
|
</ThemedChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a breaking change. The migration cost is bounded — only `AgentInstance.tsx` in the server repo uses these components.
|
||||||
|
|
||||||
|
## ThemedChart API
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number // default 200
|
||||||
|
xDataKey?: string // default "time"
|
||||||
|
xType?: 'number' | 'category' // default "category"
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode // Recharts elements
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ThemedChart renders internally:
|
||||||
|
- `ResponsiveContainer` (width 100%, height from prop)
|
||||||
|
- `ComposedChart` (supports mixing Line + Bar + Area in one chart)
|
||||||
|
- `CartesianGrid` with `rechartsTheme.cartesianGrid`
|
||||||
|
- `XAxis` with `rechartsTheme.xAxis` + formatter
|
||||||
|
- `YAxis` with `rechartsTheme.yAxis` + formatter + label
|
||||||
|
- `Tooltip` with custom `ChartTooltip` component
|
||||||
|
|
||||||
|
## Tooltip
|
||||||
|
|
||||||
|
ThemedChart provides a custom `ChartTooltip` component (internal, not exported) that:
|
||||||
|
- Shows the x-value formatted as date/time in a header row (mono font, subtle border separator)
|
||||||
|
- Shows each series with colored dot + label + formatted value
|
||||||
|
- Uses DS tokens for styling (surface bg, border, shadow, mono font)
|
||||||
|
|
||||||
|
Consumers can override by passing their own `<Tooltip content={...} />` as a child. Recharts uses the last Tooltip it finds, so a consumer-provided one replaces the default.
|
||||||
|
|
||||||
|
## DS Re-exports
|
||||||
|
|
||||||
|
Selected Recharts components re-exported so consumers don't need `recharts` in their own package.json:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||||
|
export {
|
||||||
|
Line, Area, Bar, ReferenceLine, ReferenceArea,
|
||||||
|
Legend, Brush, ComposedChart,
|
||||||
|
} from 'recharts'
|
||||||
|
```
|
||||||
|
|
||||||
|
More can be added on demand.
|
||||||
|
|
||||||
|
## Consumer Migration — Server UI
|
||||||
|
|
||||||
|
`AgentInstance.tsx` has 6 charts to migrate:
|
||||||
|
|
||||||
|
| Chart | Before | After |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| CPU Usage | `<AreaChart series={cpuSeries} threshold={...}>` | `<ThemedChart><Area dataKey="cpu" /><ReferenceLine y={85} /></ThemedChart>` |
|
||||||
|
| Memory (Heap) | `<AreaChart series={heapSeries} threshold={...}>` | `<ThemedChart><Area dataKey="heap" /><ReferenceLine y={max} /></ThemedChart>` |
|
||||||
|
| Throughput | `<LineChart series={throughputSeries}>` | `<ThemedChart><Line dataKey="throughput" /></ThemedChart>` |
|
||||||
|
| Error Rate | `<LineChart series={errorSeries}>` | `<ThemedChart><Line dataKey="errorPct" /></ThemedChart>` |
|
||||||
|
| Thread Count | `<LineChart series={threadSeries}>` | `<ThemedChart><Line dataKey="threads" /></ThemedChart>` |
|
||||||
|
| GC Pauses | `<AreaChart series={gcSeries}>` | `<ThemedChart><Area dataKey="gc" /></ThemedChart>` |
|
||||||
|
|
||||||
|
Data prep changes from building `ChartSeries[]` arrays to flat objects with named keys.
|
||||||
|
|
||||||
|
## Not in Scope
|
||||||
|
|
||||||
|
- Sparkline migration (no responsiveness issues, no axes/tooltips)
|
||||||
|
- SaaS UI (no chart component usage)
|
||||||
|
- Dashboard tab (already uses Recharts directly with `rechartsTheme`)
|
||||||
|
- New chart types (treemap, radar, etc. — consumers compose these directly)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Sidebar Section Layout: Top/Bottom Positioning & Scrollable Sections
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Extend `Sidebar.Section` with two new optional props:
|
||||||
|
|
||||||
|
- `position: 'top' | 'bottom'` — controls whether a section stacks from the top of the sidebar or from the bottom (above the footer). Default: `'top'`.
|
||||||
|
- `maxHeight: string` — CSS length value (e.g. `"250px"`, `"30vh"`) that constrains the section's content area. When content exceeds this height, it scrolls. The section header/toggle always remains visible.
|
||||||
|
|
||||||
|
This enables a layout where primary sections (Applications, Agents) occupy the top of the sidebar, while secondary sections (Routes, Starred) cluster near the bottom — with a flexible spacer absorbing remaining vertical space between the two groups.
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
### `Sidebar.Section` — new optional props
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SidebarSectionProps {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
active?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
position?: 'top' | 'bottom' // default: 'top'
|
||||||
|
maxHeight?: string // CSS length, e.g. "250px", "30vh"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No changes to `Sidebar.Header`, `Sidebar.Footer`, `Sidebar.FooterLink`, or `SidebarRoot` props.
|
||||||
|
|
||||||
|
### Consumer usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Header ... />
|
||||||
|
|
||||||
|
<Sidebar.Section label="Applications" ...>
|
||||||
|
<SidebarTree ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="Agents" ...>
|
||||||
|
<SidebarTree ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="Routes" position="bottom" maxHeight="200px" ...>
|
||||||
|
<SidebarTree ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="Starred" position="bottom" ...>
|
||||||
|
...
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Footer>...</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout Model
|
||||||
|
|
||||||
|
### SidebarRoot child partitioning
|
||||||
|
|
||||||
|
`SidebarRoot` already inspects children to extract `Header`. This extends the same pattern:
|
||||||
|
|
||||||
|
1. Extract `Header` children (existing)
|
||||||
|
2. Extract `Footer` children
|
||||||
|
3. Partition remaining children into `topSections` and `bottomSections` based on `position` prop (default `'top'`)
|
||||||
|
|
||||||
|
### Render structure
|
||||||
|
|
||||||
|
```
|
||||||
|
<aside class="sidebar">
|
||||||
|
[collapse toggle]
|
||||||
|
{header}
|
||||||
|
{search bar}
|
||||||
|
<div class="sectionGroup sectionGroupTop">
|
||||||
|
{topSections}
|
||||||
|
</div>
|
||||||
|
<div class="sectionSpacer" />
|
||||||
|
<div class="sectionGroup sectionGroupBottom">
|
||||||
|
{bottomSections}
|
||||||
|
</div>
|
||||||
|
{footer}
|
||||||
|
</aside>
|
||||||
|
```
|
||||||
|
|
||||||
|
When there are no bottom sections, the spacer is omitted. The layout behaves identically to today — footer's `margin-top: auto` handles positioning. Zero breaking changes for existing consumers.
|
||||||
|
|
||||||
|
### SidebarSection content wrapper
|
||||||
|
|
||||||
|
A new `.sectionContent` div wraps only the `children` inside `SidebarSection` (not the toggle header). When `maxHeight` is provided, it receives the value as an inline `max-height` style.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={styles.sectionContent}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Changes
|
||||||
|
|
||||||
|
### Group wrappers
|
||||||
|
|
||||||
|
```css
|
||||||
|
.sectionGroup {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSpacer {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Section content scrolling
|
||||||
|
|
||||||
|
```css
|
||||||
|
.sectionContent {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`maxHeight` is applied as an inline style, not a CSS class, since it varies per section instance.
|
||||||
|
|
||||||
|
### Custom scrollbars
|
||||||
|
|
||||||
|
Applied to both `.sectionGroup` and `.sectionContent` to keep the dark sidebar aesthetic:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Standard (Firefox, modern Chrome/Edge) */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
|
||||||
|
/* WebKit (Safari, older Chrome) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collapsed Sidebar Behavior
|
||||||
|
|
||||||
|
In collapsed (icon rail) mode, sections render as single icon buttons with no scrollable content:
|
||||||
|
|
||||||
|
- **`position`**: Respected — bottom sections render in the bottom group, so their icons cluster near the footer in the rail.
|
||||||
|
- **`maxHeight`**: Ignored — no content to constrain.
|
||||||
|
|
||||||
|
The group wrapper and spacer structure remains active in collapsed mode for consistent icon positioning.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| All sections `"top"` (no bottom sections) | No spacer rendered. Identical to current layout. |
|
||||||
|
| All sections `"bottom"` | Top group empty. Sections cluster above footer. |
|
||||||
|
| `maxHeight` set but content is shorter | No visual effect — wrapper is naturally smaller than max. |
|
||||||
|
| Very short viewport | Both group wrappers scroll independently via `overflow-y: auto` on `.sectionGroup`. |
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `src/design-system/layout/Sidebar/Sidebar.tsx` — add `position` and `maxHeight` props to `SidebarSectionProps`, add `sectionContent` wrapper to `SidebarSection`, partition children in `SidebarRoot`
|
||||||
|
- `src/design-system/layout/Sidebar/Sidebar.module.css` — add `.sectionGroup`, `.sectionSpacer`, `.sectionContent`, custom scrollbar styles
|
||||||
|
- `src/design-system/layout/Sidebar/Sidebar.test.tsx` — new test cases for positioning and scrolling behavior
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Section with `maxHeight` renders content wrapper with correct inline style and `overflow-y: auto`
|
||||||
|
- Sections with `position="bottom"` render inside the bottom group wrapper
|
||||||
|
- Default `position` (omitted) renders in the top group
|
||||||
|
- When no bottom sections exist, no spacer is rendered
|
||||||
|
- Collapsed sidebar renders bottom sections in the bottom group
|
||||||
165
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Admin - User Management (/admin/rbac)', () => {
|
||||||
|
test('renders admin tabs and user table', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Admin navigation tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible()
|
||||||
|
|
||||||
|
// User Management sub-tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar breadcrumb
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Users, Groups, and Roles tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Default tab is Users
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
|
||||||
|
// Switch to Groups tab
|
||||||
|
await page.getByRole('tab', { name: 'Groups' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch to Roles tab
|
||||||
|
await page.getByRole('tab', { name: 'Roles' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch back to Users
|
||||||
|
await page.getByRole('tab', { name: 'Users' }).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating between admin sections via tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Click Audit Log tab
|
||||||
|
await page.getByRole('tab', { name: 'Audit Log' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/audit/)
|
||||||
|
|
||||||
|
// Click OIDC tab
|
||||||
|
await page.getByRole('tab', { name: 'OIDC' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/oidc/)
|
||||||
|
|
||||||
|
// Back to User Management
|
||||||
|
await page.getByRole('tab', { name: 'User Management' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/rbac/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - Audit Log (/admin/audit)', () => {
|
||||||
|
test('renders audit table with filters', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Filter inputs exist
|
||||||
|
await expect(page.getByPlaceholder('Filter by user...')).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('Search action or target...')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filtering audit events by search', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
const searchInput = page.getByPlaceholder('Search action or target...')
|
||||||
|
await searchInput.fill('deploy')
|
||||||
|
|
||||||
|
// Table should update
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
const count = await rows.count()
|
||||||
|
expect(count).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - OIDC Config (/admin/oidc)', () => {
|
||||||
|
test('renders OIDC form with all fields', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Section headers
|
||||||
|
await expect(page.getByText('Behavior')).toBeVisible()
|
||||||
|
await expect(page.getByText('Provider Settings')).toBeVisible()
|
||||||
|
await expect(page.getByText('Claim Mapping')).toBeVisible()
|
||||||
|
await expect(page.getByText('Default Roles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Danger Zone')).toBeVisible()
|
||||||
|
|
||||||
|
// Form fields by id
|
||||||
|
await expect(page.locator('#issuer')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-id')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-secret')).toBeVisible()
|
||||||
|
await expect(page.locator('#roles-claim')).toBeVisible()
|
||||||
|
await expect(page.locator('#name-claim')).toBeVisible()
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible()
|
||||||
|
|
||||||
|
// Default roles tags
|
||||||
|
await expect(page.getByText('USER').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('VIEWER').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggling Enabled switch', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// The Toggle's checkbox is visually hidden — click the label wrapper instead
|
||||||
|
const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' })
|
||||||
|
await enabledLabel.click()
|
||||||
|
|
||||||
|
// Should not crash; label still visible
|
||||||
|
await expect(enabledLabel).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adding and removing a role tag', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Add a new role
|
||||||
|
const roleInput = page.getByPlaceholder('Add role...')
|
||||||
|
await roleInput.fill('EDITOR')
|
||||||
|
// Use the Add button next to the input (scoped to same row)
|
||||||
|
await roleInput.press('Enter')
|
||||||
|
|
||||||
|
// New role tag should appear
|
||||||
|
await expect(page.getByText('EDITOR')).toBeVisible()
|
||||||
|
|
||||||
|
// Remove it via aria-label on the tag's remove button
|
||||||
|
await page.getByRole('button', { name: 'Remove EDITOR' }).click()
|
||||||
|
|
||||||
|
// EDITOR tag should be gone
|
||||||
|
await expect(page.getByText('EDITOR')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Save button shows success toast', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
await expect(page.getByText('Settings saved')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Delete button shows confirmation dialog', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Delete OIDC/i }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog should appear
|
||||||
|
await expect(page.getByText('Delete OIDC configuration?')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
80
e2e/agents.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Agent Health (/agents)', () => {
|
||||||
|
test('renders stat cards and group cards', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Stat strip
|
||||||
|
await expect(page.getByText('Total Agents')).toBeVisible()
|
||||||
|
await expect(page.getByText('Total TPS')).toBeVisible()
|
||||||
|
|
||||||
|
// Group cards for each application
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('notification-hub').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Instance tables have data
|
||||||
|
const instanceRows = page.locator('table tbody tr')
|
||||||
|
expect(await instanceRows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Instance table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline section
|
||||||
|
await expect(page.getByText('Timeline').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking an instance row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click first instance row
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Detail panel opens — look for detail-specific labels
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Throughput').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detail panel has Performance tab with charts', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click an instance to open detail panel
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Wait for panel to open
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
|
||||||
|
// DetailPanel tabs are plain buttons (not role="tab")
|
||||||
|
// Switch to Performance tab
|
||||||
|
const perfTab = page.getByRole('button', { name: 'Performance' })
|
||||||
|
await perfTab.click()
|
||||||
|
|
||||||
|
// Performance charts should render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped agents view', async ({ page }) => {
|
||||||
|
await page.goto('/agents/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb/scope shows app
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible()
|
||||||
|
|
||||||
|
// Only order-service agents should show
|
||||||
|
await expect(page.getByText('ord-1').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-2').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-3').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dead agent shows alert banner', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// notification-hub has a dead instance, should show alert
|
||||||
|
await expect(page.getByText('Single point of failure')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
90
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */
|
||||||
|
async function widenTimeRange(page: import('@playwright/test').Page) {
|
||||||
|
await page.getByRole('tab', { name: '7d' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Dashboard (/apps)', () => {
|
||||||
|
test('renders KPI stat cards and exchange table', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// KPI health strip renders
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data rows
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Sidebar renders with app names
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar renders
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible()
|
||||||
|
await expect(page.getByText('PRODUCTION')).toBeVisible()
|
||||||
|
|
||||||
|
// Shortcuts bar
|
||||||
|
await expect(page.getByText('Ctrl+K').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a table row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Click the first data row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Detail panel should open — look for "Open full details" link
|
||||||
|
await expect(page.getByText('Open full details')).toBeVisible()
|
||||||
|
// Overview section
|
||||||
|
await expect(page.getByText('Correlation').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => {
|
||||||
|
await page.goto('/apps/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows app scope
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table should still render
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sidebar navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
|
||||||
|
// Click on an app in the sidebar
|
||||||
|
const sidebarApp = page.getByText('order-service').first()
|
||||||
|
await sidebarApp.click()
|
||||||
|
|
||||||
|
// URL should change to the app scope
|
||||||
|
await expect(page).toHaveURL(/\/apps\/order-service/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inspect button navigates to exchange detail', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Wait for table rows to appear
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
|
||||||
|
// Click the inspect button (↗) on first row
|
||||||
|
const inspectBtn = firstRow.locator('button[title="Inspect exchange"]')
|
||||||
|
await inspectBtn.click()
|
||||||
|
|
||||||
|
// Should navigate to exchange detail page
|
||||||
|
await expect(page).toHaveURL(/\/exchanges\//)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
e2e/exchanges.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Exchange Detail (/exchanges/:id)', () => {
|
||||||
|
test('renders exchange header, timeline, and message panels', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Exchange header — use the one NOT in the breadcrumb
|
||||||
|
await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible()
|
||||||
|
|
||||||
|
// Header stats
|
||||||
|
await expect(page.getByText('Duration').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Processors').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Processor Timeline section
|
||||||
|
await expect(page.getByText('Processor Timeline').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline/Flow toggle buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible()
|
||||||
|
|
||||||
|
// Message IN panel
|
||||||
|
await expect(page.getByText('Message IN')).toBeVisible()
|
||||||
|
await expect(page.getByText('Headers').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Body').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Timeline and Flow view', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Default view is timeline (gantt)
|
||||||
|
const timelineBtn = page.getByRole('button', { name: 'Timeline' })
|
||||||
|
const flowBtn = page.getByRole('button', { name: 'Flow' })
|
||||||
|
|
||||||
|
// Switch to Flow view
|
||||||
|
await flowBtn.click()
|
||||||
|
|
||||||
|
// Flow view should render (RouteFlow component)
|
||||||
|
await expect(flowBtn).toHaveClass(/active|Active/)
|
||||||
|
|
||||||
|
// Switch back to Timeline
|
||||||
|
await timelineBtn.click()
|
||||||
|
await expect(timelineBtn).toHaveClass(/active|Active/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('not-found exchange shows warning', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/nonexistent-id')
|
||||||
|
|
||||||
|
await expect(page.getByText('not found', { exact: false })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('breadcrumb navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Click Applications breadcrumb to go back
|
||||||
|
const appsBreadcrumb = page.getByRole('link', { name: 'Applications' })
|
||||||
|
await appsBreadcrumb.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/apps/)
|
||||||
|
})
|
||||||
|
})
|
||||||
63
e2e/routes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Routes (/routes)', () => {
|
||||||
|
test('renders KPI cards, route table, and charts', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// KPI cards
|
||||||
|
await expect(page.getByText('Total Throughput')).toBeVisible()
|
||||||
|
await expect(page.getByText('System Error Rate')).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency Percentiles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Active Routes')).toBeVisible()
|
||||||
|
await expect(page.getByText('In-Flight Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Route performance table
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible()
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Charts render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency (ms)')).toBeVisible()
|
||||||
|
await expect(page.getByText('Errors by Route')).toBeVisible()
|
||||||
|
await expect(page.getByText('Message Volume (msg/min)')).toBeVisible()
|
||||||
|
|
||||||
|
// Auto-refresh indicator
|
||||||
|
await expect(page.getByText('Auto-refresh: 30s')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a route row navigates to route detail', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// Click first route row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Should navigate to route detail
|
||||||
|
await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/)
|
||||||
|
|
||||||
|
// Route detail view: processor performance table
|
||||||
|
await expect(page.getByText('Processor Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible()
|
||||||
|
|
||||||
|
// Route Flow diagram
|
||||||
|
await expect(page.getByText('Route Flow')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped routes view filters data', async ({ page }) => {
|
||||||
|
await page.goto('/routes/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows scope
|
||||||
|
await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible()
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table still renders
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
1148
package-lock.json
generated
13
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.6",
|
"version": "0.1.53",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.es.js",
|
"main": "./dist/index.es.js",
|
||||||
"module": "./dist/index.es.js",
|
"module": "./dist/index.es.js",
|
||||||
@@ -10,10 +10,12 @@
|
|||||||
"types": "./dist/index.es.d.ts",
|
"types": "./dist/index.es.d.ts",
|
||||||
"import": "./dist/index.es.js"
|
"import": "./dist/index.es.js"
|
||||||
},
|
},
|
||||||
"./style.css": "./dist/style.css"
|
"./style.css": "./dist/style.css",
|
||||||
|
"./assets/*": "./assets/*"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"assets"
|
||||||
],
|
],
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
"*.css"
|
"*.css"
|
||||||
@@ -35,9 +37,11 @@
|
|||||||
"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",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -52,6 +56,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
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
35
src/App.tsx
@@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData'
|
|||||||
import { exchanges } from './mocks/exchanges'
|
import { 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,21 +79,23 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
<Route element={<LayoutShell />}>
|
||||||
<Route path="/apps" element={<Dashboard />} />
|
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||||
<Route path="/apps/:id" element={<Dashboard />} />
|
<Route path="/apps" element={<Dashboard />} />
|
||||||
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
|
<Route path="/apps/:id" element={<Dashboard />} />
|
||||||
<Route path="/routes" element={<RoutesPage />} />
|
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
|
||||||
<Route path="/routes/:appId" element={<RoutesPage />} />
|
<Route path="/routes" element={<RoutesPage />} />
|
||||||
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
|
<Route path="/routes/:appId" element={<RoutesPage />} />
|
||||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
|
||||||
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
|
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||||
<Route path="/agents/*" element={<AgentHealth />} />
|
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
|
||||||
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
<Route path="/agents/*" element={<AgentHealth />} />
|
||||||
<Route path="/admin/audit" element={<AuditLog />} />
|
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||||||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
<Route path="/admin/audit" element={<AuditLog />} />
|
||||||
<Route path="/admin/rbac" element={<UserManagement />} />
|
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||||
<Route path="/api-docs" element={<ApiDocs />} />
|
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||||
|
<Route path="/api-docs" element={<ApiDocs />} />
|
||||||
|
</Route>
|
||||||
<Route path="/inventory" element={<Inventory />} />
|
<Route path="/inventory" element={<Inventory />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
|
|||||||
BIN
src/assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
112
src/assets/cameleer3-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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({
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid */
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Threshold line */
|
|
||||||
.thresholdLine {
|
|
||||||
stroke: var(--error);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-dasharray: 5 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Area + line */
|
|
||||||
.area {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Crosshair */
|
|
||||||
.crosshair {
|
|
||||||
stroke: var(--text-faint);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip */
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Legend */
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Axis labels */
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import styles from './AreaChart.module.css'
|
|
||||||
import {
|
|
||||||
computeYScale,
|
|
||||||
computeXScale,
|
|
||||||
seriesPoints,
|
|
||||||
seriesPath,
|
|
||||||
formatAxisLabel,
|
|
||||||
CHART_COLORS,
|
|
||||||
type ChartSeries,
|
|
||||||
} from '../_chart-utils'
|
|
||||||
|
|
||||||
interface Threshold {
|
|
||||||
value: number
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AreaChartProps {
|
|
||||||
series: ChartSeries[]
|
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
|
||||||
threshold?: Threshold
|
|
||||||
height?: number
|
|
||||||
width?: number
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const Y_TICK_COUNT = 4
|
|
||||||
const DIMS = {
|
|
||||||
paddingTop: 12,
|
|
||||||
paddingRight: 16,
|
|
||||||
paddingBottom: 28,
|
|
||||||
paddingLeft: 48,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AreaChart({
|
|
||||||
series,
|
|
||||||
xLabel,
|
|
||||||
yLabel,
|
|
||||||
threshold,
|
|
||||||
height = 200,
|
|
||||||
width = 400,
|
|
||||||
className,
|
|
||||||
}: AreaChartProps) {
|
|
||||||
const [tooltip, setTooltip] = useState<{
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
values: { label: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const dims = { ...DIMS, width, height }
|
|
||||||
const allData = series.flatMap((s) => s.data)
|
|
||||||
|
|
||||||
if (allData.length === 0) {
|
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const { max, toY } = computeYScale(series, dims, threshold?.value)
|
|
||||||
const { toX } = computeXScale(series, dims)
|
|
||||||
const plotH = height - dims.paddingTop - dims.paddingBottom
|
|
||||||
const plotW = width - dims.paddingLeft - dims.paddingRight
|
|
||||||
const bottomY = dims.paddingTop + plotH
|
|
||||||
|
|
||||||
// Y-axis ticks
|
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
|
||||||
Math.round((max / Y_TICK_COUNT) * i),
|
|
||||||
)
|
|
||||||
|
|
||||||
// X-axis ticks (first, middle, last)
|
|
||||||
const firstSeries = series[0]
|
|
||||||
const xSamples =
|
|
||||||
firstSeries && firstSeries.data.length > 0
|
|
||||||
? [
|
|
||||||
firstSeries.data[0].x,
|
|
||||||
firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
|
|
||||||
firstSeries.data[firstSeries.data.length - 1].x,
|
|
||||||
].filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const mx = e.clientX - rect.left
|
|
||||||
const my = e.clientY - rect.top
|
|
||||||
|
|
||||||
// Find closest x value
|
|
||||||
const pctX = (mx - dims.paddingLeft) / plotW
|
|
||||||
const values = series.map((s, i) => {
|
|
||||||
const idx = Math.round(pctX * (s.data.length - 1))
|
|
||||||
const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
|
|
||||||
const pt = s.data[clamped]
|
|
||||||
return {
|
|
||||||
label: s.label,
|
|
||||||
value: pt?.y ?? 0,
|
|
||||||
color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTooltip({ x: mx, y: my, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className={styles.svg}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={() => setTooltip(null)}
|
|
||||||
aria-label="Area chart"
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={y}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* X-axis labels */}
|
|
||||||
{xSamples.map((xVal, i) => {
|
|
||||||
const xPos = toX(xVal)
|
|
||||||
const xv = xVal instanceof Date ? xVal : new Date(xVal as number)
|
|
||||||
const label =
|
|
||||||
xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10)
|
|
||||||
? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: formatAxisLabel(xVal as number)
|
|
||||||
const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle'
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={xPos}
|
|
||||||
y={height - dims.paddingBottom + 16}
|
|
||||||
className={styles.axisLabel}
|
|
||||||
textAnchor={anchor}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* SLA threshold line */}
|
|
||||||
{threshold && (
|
|
||||||
<g>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={toY(threshold.value)}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={toY(threshold.value)}
|
|
||||||
className={styles.thresholdLine}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={width - dims.paddingRight - 4}
|
|
||||||
y={toY(threshold.value) - 4}
|
|
||||||
className={styles.thresholdLabel}
|
|
||||||
textAnchor="end"
|
|
||||||
>
|
|
||||||
{threshold.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Area fills */}
|
|
||||||
{series.map((s, i) => {
|
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
|
||||||
const areaD = seriesPath(s, toX, toY, bottomY)
|
|
||||||
return (
|
|
||||||
<path
|
|
||||||
key={`area-${i}`}
|
|
||||||
d={areaD}
|
|
||||||
fill={color}
|
|
||||||
className={styles.area}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Lines */}
|
|
||||||
{series.map((s, i) => {
|
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
|
||||||
const pts = seriesPoints(s, toX, toY)
|
|
||||||
return (
|
|
||||||
<polyline
|
|
||||||
key={`line-${i}`}
|
|
||||||
points={pts}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
className={styles.line}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Crosshair */}
|
|
||||||
{tooltip && (
|
|
||||||
<line
|
|
||||||
x1={tooltip.x}
|
|
||||||
y1={dims.paddingTop}
|
|
||||||
x2={tooltip.x}
|
|
||||||
y2={bottomY}
|
|
||||||
className={styles.crosshair}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{
|
|
||||||
left: tooltip.x + 12,
|
|
||||||
top: tooltip.y,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.label} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.label}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
|
||||||
<div key={s.label} className={styles.legendItem}>
|
|
||||||
<span
|
|
||||||
className={styles.legendDot}
|
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
background-color: var(--bg-inset);
|
background-color: var(--bg-inset);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: -8px;
|
margin-left: -8px;
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.catLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipTitle {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import styles from './BarChart.module.css'
|
|
||||||
import { formatAxisLabel, CHART_COLORS } from '../_chart-utils'
|
|
||||||
|
|
||||||
interface BarSeries {
|
|
||||||
label: string
|
|
||||||
data: { x: string; y: number }[]
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BarChartProps {
|
|
||||||
series: BarSeries[]
|
|
||||||
stacked?: boolean
|
|
||||||
height?: number
|
|
||||||
width?: number
|
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PADDING = { top: 12, right: 16, bottom: 40, left: 48 }
|
|
||||||
const Y_TICK_COUNT = 4
|
|
||||||
const BAR_GAP = 0.2 // fraction of bar group width reserved for gaps
|
|
||||||
|
|
||||||
export function BarChart({
|
|
||||||
series,
|
|
||||||
stacked = false,
|
|
||||||
height = 200,
|
|
||||||
width = 400,
|
|
||||||
xLabel,
|
|
||||||
yLabel,
|
|
||||||
className,
|
|
||||||
}: BarChartProps) {
|
|
||||||
const [tooltip, setTooltip] = useState<{
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
label: string
|
|
||||||
values: { series: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
if (series.length === 0 || series[0].data.length === 0) {
|
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all x categories (union across all series)
|
|
||||||
const categories = Array.from(new Set(series.flatMap((s) => s.data.map((d) => d.x))))
|
|
||||||
const numCats = categories.length
|
|
||||||
|
|
||||||
const plotW = width - PADDING.left - PADDING.right
|
|
||||||
const plotH = height - PADDING.top - PADDING.bottom
|
|
||||||
|
|
||||||
// Compute max Y
|
|
||||||
let maxY = 0
|
|
||||||
if (stacked) {
|
|
||||||
for (const cat of categories) {
|
|
||||||
const sum = series.reduce((acc, s) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
return acc + (pt?.y ?? 0)
|
|
||||||
}, 0)
|
|
||||||
maxY = Math.max(maxY, sum)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
maxY = Math.max(...series.flatMap((s) => s.data.map((d) => d.y)))
|
|
||||||
}
|
|
||||||
maxY = maxY || 1
|
|
||||||
|
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
|
||||||
Math.round((maxY / Y_TICK_COUNT) * i),
|
|
||||||
)
|
|
||||||
const toY = (val: number) => PADDING.top + plotH - (val / maxY) * plotH
|
|
||||||
const bottomY = PADDING.top + plotH
|
|
||||||
|
|
||||||
const catWidth = plotW / numCats
|
|
||||||
const groupGap = catWidth * BAR_GAP
|
|
||||||
const groupW = catWidth - groupGap
|
|
||||||
|
|
||||||
function handleMouseEnter(
|
|
||||||
catLabel: string,
|
|
||||||
mx: number,
|
|
||||||
my: number,
|
|
||||||
values: { series: string; value: number; color: string }[],
|
|
||||||
) {
|
|
||||||
setTooltip({ x: mx, y: my, label: catLabel, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className={styles.svg}
|
|
||||||
onMouseLeave={() => setTooltip(null)}
|
|
||||||
aria-label="Bar chart"
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={PADDING.left}
|
|
||||||
y1={y}
|
|
||||||
x2={width - PADDING.right}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={PADDING.left - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Bars */}
|
|
||||||
{categories.map((cat, ci) => {
|
|
||||||
const groupX = PADDING.left + ci * catWidth + groupGap / 2
|
|
||||||
|
|
||||||
if (stacked) {
|
|
||||||
let stackY = bottomY
|
|
||||||
return (
|
|
||||||
<g key={cat}>
|
|
||||||
{series.map((s, si) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
const val = pt?.y ?? 0
|
|
||||||
const barH = (val / maxY) * plotH
|
|
||||||
const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length]
|
|
||||||
const y = stackY - barH
|
|
||||||
stackY -= barH
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
key={si}
|
|
||||||
x={groupX}
|
|
||||||
y={y}
|
|
||||||
width={groupW}
|
|
||||||
height={barH}
|
|
||||||
fill={color}
|
|
||||||
className={styles.bar}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - rect.left,
|
|
||||||
e.clientY - rect.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<text
|
|
||||||
x={groupX + groupW / 2}
|
|
||||||
y={bottomY + 14}
|
|
||||||
className={styles.catLabel}
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grouped
|
|
||||||
const barW = groupW / series.length
|
|
||||||
return (
|
|
||||||
<g key={cat}>
|
|
||||||
{series.map((s, si) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
const val = pt?.y ?? 0
|
|
||||||
const barH = (val / maxY) * plotH
|
|
||||||
const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length]
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
key={si}
|
|
||||||
x={groupX + si * barW}
|
|
||||||
y={toY(val)}
|
|
||||||
width={barW - 1}
|
|
||||||
height={barH}
|
|
||||||
fill={color}
|
|
||||||
className={styles.bar}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
const svgEl = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - svgEl.left,
|
|
||||||
e.clientY - svgEl.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<text
|
|
||||||
x={groupX + groupW / 2}
|
|
||||||
y={bottomY + 14}
|
|
||||||
className={styles.catLabel}
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{ left: tooltip.x + 12, top: tooltip.y }}
|
|
||||||
>
|
|
||||||
<div className={styles.tooltipTitle}>{tooltip.label}</div>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.series} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.series}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
|
||||||
<div key={s.label} className={styles.legendItem}>
|
|
||||||
<span
|
|
||||||
className={styles.legendDot}
|
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
.sep {
|
.sep {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ interface BreadcrumbItem {
|
|||||||
interface BreadcrumbProps {
|
interface BreadcrumbProps {
|
||||||
items: BreadcrumbItem[]
|
items: BreadcrumbItem[]
|
||||||
className?: string
|
className?: string
|
||||||
|
onNavigate?: (href: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
export function Breadcrumb({ items, className, onNavigate }: BreadcrumbProps) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Breadcrumb" className={className}>
|
<nav aria-label="Breadcrumb" className={className}>
|
||||||
<ol className={styles.list}>
|
<ol className={styles.list}>
|
||||||
@@ -22,7 +23,11 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
|||||||
{isLast ? (
|
{isLast ? (
|
||||||
<span className={styles.active}>{item.label}</span>
|
<span className={styles.active}>{item.label}</span>
|
||||||
) : item.href ? (
|
) : item.href ? (
|
||||||
<a href={item.href} className={styles.link}>
|
<a
|
||||||
|
href={item.href}
|
||||||
|
className={styles.link}
|
||||||
|
onClick={onNavigate ? (e) => { e.preventDefault(); onNavigate(item.href!) } : undefined}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
border: 1px solid var(--amber-light);
|
border: 1px solid var(--amber-light);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -239,13 +239,13 @@
|
|||||||
|
|
||||||
.itemTime {
|
.itemTime {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemMeta {
|
.itemMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -277,6 +277,23 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Match context snippet */
|
||||||
|
.matchContext {
|
||||||
|
font-size: 12px;
|
||||||
|
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;
|
||||||
@@ -299,6 +316,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -25,7 +42,7 @@
|
|||||||
.th {
|
.th {
|
||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.8px;
|
letter-spacing: 0.8px;
|
||||||
@@ -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 {
|
||||||
@@ -127,7 +147,7 @@
|
|||||||
|
|
||||||
.rangeInfo {
|
.rangeInfo {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginationRight {
|
.paginationRight {
|
||||||
@@ -137,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pageSizeLabel {
|
.pageSizeLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +173,7 @@
|
|||||||
|
|
||||||
.pageNum {
|
.pageNum {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,51 +23,54 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
|||||||
|
|
||||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||||
|
|
||||||
const panel = (
|
const content = (
|
||||||
<aside
|
<>
|
||||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
|
||||||
aria-hidden={!open}
|
<aside
|
||||||
>
|
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||||
<div className={styles.header}>
|
aria-hidden={!open}
|
||||||
<span className={styles.title}>{title}</span>
|
>
|
||||||
<button
|
<div className={styles.header}>
|
||||||
className={styles.closeBtn}
|
<span className={styles.title}>{title}</span>
|
||||||
onClick={onClose}
|
<button
|
||||||
aria-label="Close panel"
|
className={styles.closeBtn}
|
||||||
type="button"
|
onClick={onClose}
|
||||||
>
|
aria-label="Close panel"
|
||||||
×
|
type="button"
|
||||||
</button>
|
>
|
||||||
</div>
|
×
|
||||||
|
</button>
|
||||||
{tabs && tabs.length > 0 && (
|
|
||||||
<div className={styles.tabs}>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.value}
|
|
||||||
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab.value)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.body}>
|
{tabs && tabs.length > 0 && (
|
||||||
{children ?? activeContent}
|
<div className={styles.tabs}>
|
||||||
</div>
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{actions && (
|
<div className={styles.body}>
|
||||||
<div className={styles.actions}>
|
{children ?? activeContent}
|
||||||
{actions}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
{actions && (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Portal to AppShell level if target exists, otherwise render in place
|
// Portal to AppShell level if target exists, otherwise render in place
|
||||||
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||||
return portalTarget ? createPortal(panel, portalTarget) : panel
|
return portalTarget ? createPortal(content, portalTarget) : content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
border: 1px solid var(--amber-light);
|
border: 1px solid var(--amber-light);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
color: var(--amber-deep);
|
color: var(--amber-deep);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clearAll {
|
.clearAll {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
.trend {
|
.trend {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
.trendMuted { color: var(--text-muted); }
|
.trendMuted { color: var(--text-muted); }
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,146 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLine {
|
|
||||||
stroke: var(--error);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-dasharray: 5 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crosshair {
|
|
||||||
stroke: var(--text-faint);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import styles from './LineChart.module.css'
|
|
||||||
import {
|
|
||||||
computeYScale,
|
|
||||||
computeXScale,
|
|
||||||
seriesPoints,
|
|
||||||
formatAxisLabel,
|
|
||||||
CHART_COLORS,
|
|
||||||
type ChartSeries,
|
|
||||||
} from '../_chart-utils'
|
|
||||||
|
|
||||||
interface Threshold {
|
|
||||||
value: number
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LineChartProps {
|
|
||||||
series: ChartSeries[]
|
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
|
||||||
threshold?: Threshold
|
|
||||||
height?: number
|
|
||||||
width?: number
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const Y_TICK_COUNT = 4
|
|
||||||
const DIMS = {
|
|
||||||
paddingTop: 12,
|
|
||||||
paddingRight: 16,
|
|
||||||
paddingBottom: 28,
|
|
||||||
paddingLeft: 48,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LineChart({
|
|
||||||
series,
|
|
||||||
xLabel,
|
|
||||||
yLabel,
|
|
||||||
threshold,
|
|
||||||
height = 200,
|
|
||||||
width = 400,
|
|
||||||
className,
|
|
||||||
}: LineChartProps) {
|
|
||||||
const [tooltip, setTooltip] = useState<{
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
values: { label: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const dims = { ...DIMS, width, height }
|
|
||||||
const allData = series.flatMap((s) => s.data)
|
|
||||||
|
|
||||||
if (allData.length === 0) {
|
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const { max, toY } = computeYScale(series, dims, threshold?.value)
|
|
||||||
const { toX } = computeXScale(series, dims)
|
|
||||||
const plotH = height - dims.paddingTop - dims.paddingBottom
|
|
||||||
const plotW = width - dims.paddingLeft - dims.paddingRight
|
|
||||||
const bottomY = dims.paddingTop + plotH
|
|
||||||
|
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
|
||||||
Math.round((max / Y_TICK_COUNT) * i),
|
|
||||||
)
|
|
||||||
|
|
||||||
const firstSeries = series[0]
|
|
||||||
const xSamples =
|
|
||||||
firstSeries && firstSeries.data.length > 0
|
|
||||||
? [
|
|
||||||
firstSeries.data[0].x,
|
|
||||||
firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
|
|
||||||
firstSeries.data[firstSeries.data.length - 1].x,
|
|
||||||
].filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const mx = e.clientX - rect.left
|
|
||||||
const my = e.clientY - rect.top
|
|
||||||
const pctX = (mx - dims.paddingLeft) / plotW
|
|
||||||
|
|
||||||
const values = series.map((s, i) => {
|
|
||||||
const idx = Math.round(pctX * (s.data.length - 1))
|
|
||||||
const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
|
|
||||||
const pt = s.data[clamped]
|
|
||||||
return {
|
|
||||||
label: s.label,
|
|
||||||
value: pt?.y ?? 0,
|
|
||||||
color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTooltip({ x: mx, y: my, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className={styles.svg}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={() => setTooltip(null)}
|
|
||||||
aria-label="Line chart"
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={y}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* X-axis labels */}
|
|
||||||
{xSamples.map((xVal, i) => {
|
|
||||||
const xPos = toX(xVal)
|
|
||||||
const xv = xVal instanceof Date ? xVal : new Date(xVal as number)
|
|
||||||
const label =
|
|
||||||
xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10)
|
|
||||||
? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: formatAxisLabel(xVal as number)
|
|
||||||
const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle'
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={xPos}
|
|
||||||
y={height - dims.paddingBottom + 16}
|
|
||||||
className={styles.axisLabel}
|
|
||||||
textAnchor={anchor}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Threshold line */}
|
|
||||||
{threshold && (
|
|
||||||
<g>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={toY(threshold.value)}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={toY(threshold.value)}
|
|
||||||
className={styles.thresholdLine}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={width - dims.paddingRight - 4}
|
|
||||||
y={toY(threshold.value) - 4}
|
|
||||||
className={styles.thresholdLabel}
|
|
||||||
textAnchor="end"
|
|
||||||
>
|
|
||||||
{threshold.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lines only (no area fill) */}
|
|
||||||
{series.map((s, i) => {
|
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
|
||||||
const pts = seriesPoints(s, toX, toY)
|
|
||||||
return (
|
|
||||||
<polyline
|
|
||||||
key={`line-${i}`}
|
|
||||||
points={pts}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
className={styles.line}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Crosshair */}
|
|
||||||
{tooltip && (
|
|
||||||
<line
|
|
||||||
x1={tooltip.x}
|
|
||||||
y1={dims.paddingTop}
|
|
||||||
x2={tooltip.x}
|
|
||||||
y2={bottomY}
|
|
||||||
className={styles.crosshair}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{ left: tooltip.x + 12, top: tooltip.y }}
|
|
||||||
>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.label} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.label}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
|
||||||
<div key={s.label} className={styles.legendItem}>
|
|
||||||
<span
|
|
||||||
className={styles.legendDot}
|
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
.container {
|
.container {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg-inset);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -20,7 +18,7 @@
|
|||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 56px;
|
min-width: 56px;
|
||||||
}
|
}
|
||||||
@@ -58,6 +56,43 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceContainer {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceApp {
|
||||||
|
color: var(--running);
|
||||||
|
background: color-mix(in srgb, var(--running) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceAgent {
|
||||||
|
color: var(--warning);
|
||||||
|
background: color-mix(in srgb, var(--warning) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceDefault {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: color-mix(in srgb, var(--text-muted) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { render, screen } from '@testing-library/react'
|
|||||||
import { LogViewer, type LogEntry } from './LogViewer'
|
import { LogViewer, type LogEntry } from './LogViewer'
|
||||||
|
|
||||||
const entries: LogEntry[] = [
|
const entries: LogEntry[] = [
|
||||||
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' },
|
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started', source: 'app' },
|
||||||
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage', source: 'container' },
|
||||||
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed', source: 'agent' },
|
||||||
{ 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)', () => {
|
||||||
@@ -49,6 +52,23 @@ describe('LogViewer', () => {
|
|||||||
expect(el.classList.contains('custom-class')).toBe(true)
|
expect(el.classList.contains('custom-class')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders source badges when source is provided', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByText('app')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('container')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('agent')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits source badge when source is not provided', () => {
|
||||||
|
const noSourceEntries: LogEntry[] = [
|
||||||
|
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'No source here' },
|
||||||
|
]
|
||||||
|
render(<LogViewer entries={noSourceEntries} />)
|
||||||
|
expect(screen.getByText('No source here')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('app')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('container')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('has role="log" for accessibility', () => {
|
it('has role="log" for accessibility', () => {
|
||||||
render(<LogViewer entries={entries} />)
|
render(<LogViewer entries={entries} />)
|
||||||
expect(screen.getByRole('log')).toBeInTheDocument()
|
expect(screen.getByRole('log')).toBeInTheDocument()
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ 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
|
||||||
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogViewerProps {
|
export interface LogViewerProps {
|
||||||
@@ -18,6 +19,13 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_CLASS: Record<string, string> = {
|
||||||
|
container: styles.sourceContainer,
|
||||||
|
app: styles.sourceApp,
|
||||||
|
agent: styles.sourceAgent,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
@@ -35,18 +43,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])
|
||||||
|
|
||||||
@@ -66,6 +74,11 @@ export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProp
|
|||||||
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
||||||
{entry.level.toUpperCase()}
|
{entry.level.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
{entry.source && (
|
||||||
|
<span className={[styles.sourceBadge, SOURCE_CLASS[entry.source] ?? styles.sourceDefault].join(' ')}>
|
||||||
|
{entry.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={styles.message}>{entry.message}</span>
|
<span className={styles.message}>{entry.message}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
.dividerText {
|
.dividerText {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.forgotLink {
|
.forgotLink {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.clickable {
|
.row.clickable {
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
.dur {
|
.dur {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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,14 @@
|
|||||||
|
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'
|
||||||
|
import { formatDuration } from '../../../utils/format-utils'
|
||||||
|
|
||||||
|
export interface NodeBadge {
|
||||||
|
label: string
|
||||||
|
variant?: 'info' | 'success' | 'warning' | 'error'
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface RouteNode {
|
export interface RouteNode {
|
||||||
name: string
|
name: string
|
||||||
@@ -6,21 +16,33 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
|
||||||
return `${ms}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
function durationClass(ms: number, status: string): string {
|
function durationClass(ms: number, status: string): string {
|
||||||
if (status === 'fail') return styles.durBreach
|
if (status === 'fail') return styles.durBreach
|
||||||
if (ms < 50) return styles.durFast
|
if (ms < 50) return styles.durFast
|
||||||
@@ -29,12 +51,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 +74,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 +221,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 +240,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 +267,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{formatDuration(node.durationMs)}
|
{formatDuration(node.durationMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -110,22 +276,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
|||||||
{errorHandlers.length > 0 && (
|
{errorHandlers.length > 0 && (
|
||||||
<div className={styles.errorSection}>
|
<div className={styles.errorSection}>
|
||||||
<div className={styles.errorLabel}>Error Handler</div>
|
<div className={styles.errorLabel}>Error Handler</div>
|
||||||
{errorHandlers.map((node, i) => (
|
{errorHandlers.map((node, i) => {
|
||||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
const errOriginalIndex = allNodes.indexOf(node)
|
||||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
return (
|
||||||
{TYPE_ICONS['error-handler']}
|
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||||
</div>
|
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||||
<div className={styles.info}>
|
{TYPE_ICONS['error-handler']}
|
||||||
<div className={styles.type}>{node.type}</div>
|
|
||||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.stats}>
|
|
||||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
|
||||||
{formatDuration(node.durationMs)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.type}>{node.type}</div>
|
||||||
|
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||||
|
{formatDuration(node.durationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.tooltip {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
41
src/design-system/composites/ThemedChart/ChartTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { TooltipProps } from 'recharts'
|
||||||
|
import styles from './ChartTooltip.module.css'
|
||||||
|
|
||||||
|
function formatValue(val: number): string {
|
||||||
|
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
||||||
|
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
|
||||||
|
if (Number.isInteger(val)) return String(val)
|
||||||
|
return val.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(val: unknown): string | null {
|
||||||
|
if (val == null) return null
|
||||||
|
const str = String(val)
|
||||||
|
const ms = typeof val === 'number' && val > 1e12 ? val
|
||||||
|
: typeof val === 'number' && val > 1e9 ? val * 1000
|
||||||
|
: Date.parse(str)
|
||||||
|
if (isNaN(ms)) return str
|
||||||
|
return new Date(ms).toLocaleString([], {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label }: TooltipProps<number, string>) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
|
const timeLabel = formatTimestamp(label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tooltip}>
|
||||||
|
{timeLabel && <div className={styles.time}>{timeLabel}</div>}
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.dataKey as string} className={styles.row}>
|
||||||
|
<span className={styles.dot} style={{ background: entry.color }} />
|
||||||
|
<span className={styles.label}>{entry.name}:</span>
|
||||||
|
<span className={styles.value}>{formatValue(entry.value as number)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ThemedChart } from './ThemedChart'
|
||||||
|
import { Line } from 'recharts'
|
||||||
|
|
||||||
|
// Recharts uses ResizeObserver internally
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = ResizeObserverMock as any
|
||||||
|
|
||||||
|
describe('ThemedChart', () => {
|
||||||
|
it('renders nothing when data is empty', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={[]}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a chart container when data is provided', () => {
|
||||||
|
const data = [
|
||||||
|
{ time: '10:00', value: 10 },
|
||||||
|
{ time: '10:01', value: 20 },
|
||||||
|
]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} height={160}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.recharts-responsive-container')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const data = [{ time: '10:00', value: 5 }]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} className="my-chart">
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.my-chart')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
src/design-system/composites/ThemedChart/ThemedChart.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
ComposedChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
} from 'recharts'
|
||||||
|
import { rechartsTheme } from '../../utils/rechartsTheme'
|
||||||
|
import { ChartTooltip } from './ChartTooltip'
|
||||||
|
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number
|
||||||
|
xDataKey?: string
|
||||||
|
xType?: 'number' | 'category'
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedChart({
|
||||||
|
data,
|
||||||
|
height = 200,
|
||||||
|
xDataKey = 'time',
|
||||||
|
xType = 'category',
|
||||||
|
xTickFormatter,
|
||||||
|
yTickFormatter,
|
||||||
|
yLabel,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ThemedChartProps) {
|
||||||
|
if (!data.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show ~5-6 ticks max to avoid label overlap
|
||||||
|
const maxTicks = 6
|
||||||
|
const tickInterval = data.length > maxTicks
|
||||||
|
? Math.ceil(data.length / maxTicks) - 1
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ width: '100%', height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey={xDataKey}
|
||||||
|
type={xType}
|
||||||
|
{...rechartsTheme.xAxis}
|
||||||
|
tickFormatter={xTickFormatter}
|
||||||
|
interval={tickInterval}
|
||||||
|
minTickGap={40}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
{...rechartsTheme.yAxis}
|
||||||
|
tickFormatter={yTickFormatter}
|
||||||
|
label={yLabel ? {
|
||||||
|
value: yLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
style: { fontSize: 11, fill: 'var(--text-muted)' },
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltip />} cursor={rechartsTheme.tooltip.cursor} />
|
||||||
|
{children}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</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,16 +75,15 @@ 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 => {
|
||||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
const duration = options.duration ?? DEFAULT_DURATION
|
|
||||||
const variant = options.variant ?? 'info'
|
const variant = options.variant ?? 'info'
|
||||||
|
// Error toasts persist until manually dismissed; others auto-close after DEFAULT_DURATION
|
||||||
|
const duration = options.duration ?? (variant === 'error' ? 0 : DEFAULT_DURATION)
|
||||||
|
|
||||||
const newToast: ToastItem = {
|
const newToast: ToastItem = {
|
||||||
id,
|
id,
|
||||||
@@ -96,11 +100,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
return next.slice(-MAX_TOASTS)
|
return next.slice(-MAX_TOASTS)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Schedule auto-dismiss
|
// Schedule auto-dismiss (duration 0 = persist until manual dismiss)
|
||||||
const timer = setTimeout(() => {
|
if (duration > 0) {
|
||||||
dismiss(id)
|
const timer = setTimeout(() => {
|
||||||
}, duration)
|
dismiss(id)
|
||||||
timersRef.current.set(id, timer)
|
}, duration)
|
||||||
|
timersRef.current.set(id, timer)
|
||||||
|
}
|
||||||
|
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
@@ -183,7 +189,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
export interface DataPoint {
|
|
||||||
x: number | Date
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChartSeries {
|
|
||||||
label: string
|
|
||||||
data: DataPoint[]
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChartDimensions {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
paddingTop: number
|
|
||||||
paddingRight: number
|
|
||||||
paddingBottom: number
|
|
||||||
paddingLeft: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeYScale(
|
|
||||||
series: ChartSeries[],
|
|
||||||
dims: ChartDimensions,
|
|
||||||
threshold?: number,
|
|
||||||
) {
|
|
||||||
const allY = series.flatMap((s) => s.data.map((d) => d.y))
|
|
||||||
if (threshold != null) allY.push(threshold)
|
|
||||||
const min = 0
|
|
||||||
const max = Math.max(...allY, 1)
|
|
||||||
const range = max - min
|
|
||||||
|
|
||||||
const plotH = dims.height - dims.paddingTop - dims.paddingBottom
|
|
||||||
const toY = (val: number) => dims.paddingTop + plotH - ((val - min) / range) * plotH
|
|
||||||
|
|
||||||
return { min, max, range, toY }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeXScale(series: ChartSeries[], dims: ChartDimensions) {
|
|
||||||
const allX = series.flatMap((s) =>
|
|
||||||
s.data.map((d) => (d.x instanceof Date ? d.x.getTime() : d.x)),
|
|
||||||
)
|
|
||||||
const minX = Math.min(...allX)
|
|
||||||
const maxX = Math.max(...allX)
|
|
||||||
const rangeX = maxX - minX || 1
|
|
||||||
|
|
||||||
const plotW = dims.width - dims.paddingLeft - dims.paddingRight
|
|
||||||
const toX = (val: number | Date) => {
|
|
||||||
const v = val instanceof Date ? val.getTime() : val
|
|
||||||
return dims.paddingLeft + ((v - minX) / rangeX) * plotW
|
|
||||||
}
|
|
||||||
|
|
||||||
return { minX, maxX, rangeX, toX }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function seriesPoints(
|
|
||||||
series: ChartSeries,
|
|
||||||
toX: (v: number | Date) => number,
|
|
||||||
toY: (v: number) => number,
|
|
||||||
): string {
|
|
||||||
return series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function seriesPath(
|
|
||||||
series: ChartSeries,
|
|
||||||
toX: (v: number | Date) => number,
|
|
||||||
toY: (v: number) => number,
|
|
||||||
bottomY: number,
|
|
||||||
): string {
|
|
||||||
if (series.data.length === 0) return ''
|
|
||||||
const pts = series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`)
|
|
||||||
const firstX = toX(series.data[0].x).toFixed(1)
|
|
||||||
const lastX = toX(series.data[series.data.length - 1].x).toFixed(1)
|
|
||||||
return `M${pts.join(' L')} L${lastX},${bottomY} L${firstX},${bottomY} Z`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAxisLabel(val: number): string {
|
|
||||||
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
|
||||||
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
|
|
||||||
if (Number.isInteger(val)) return String(val)
|
|
||||||
return val.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatXLabel(val: number | Date, _totalPoints: number): string {
|
|
||||||
if (val instanceof Date || (typeof val === 'number' && val > 1e10)) {
|
|
||||||
const d = val instanceof Date ? val : new Date(val)
|
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
return formatAxisLabel(val as number)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default color tokens for series (fallback when no color given)
|
|
||||||
export const CHART_COLORS = [
|
|
||||||
'var(--chart-1)',
|
|
||||||
'var(--chart-2)',
|
|
||||||
'var(--chart-3)',
|
|
||||||
'var(--chart-4)',
|
|
||||||
'var(--chart-5)',
|
|
||||||
'var(--chart-6)',
|
|
||||||
'var(--chart-7)',
|
|
||||||
'var(--chart-8)',
|
|
||||||
]
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
export { Accordion } from './Accordion/Accordion'
|
export { Accordion } from './Accordion/Accordion'
|
||||||
export { AlertDialog } from './AlertDialog/AlertDialog'
|
export { AlertDialog } from './AlertDialog/AlertDialog'
|
||||||
export { AreaChart } from './AreaChart/AreaChart'
|
|
||||||
export { AvatarGroup } from './AvatarGroup/AvatarGroup'
|
export { AvatarGroup } from './AvatarGroup/AvatarGroup'
|
||||||
export { BarChart } from './BarChart/BarChart'
|
|
||||||
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
|
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
|
||||||
export { CommandPalette } from './CommandPalette/CommandPalette'
|
export { CommandPalette } from './CommandPalette/CommandPalette'
|
||||||
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
|
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
|
||||||
@@ -13,13 +11,13 @@ export type { Column, DataTableProps } from './DataTable/types'
|
|||||||
export { DetailPanel } from './DetailPanel/DetailPanel'
|
export { DetailPanel } from './DetailPanel/DetailPanel'
|
||||||
export { EntityList } from './EntityList/EntityList'
|
export { EntityList } from './EntityList/EntityList'
|
||||||
export { Dropdown } from './Dropdown/Dropdown'
|
export { Dropdown } from './Dropdown/Dropdown'
|
||||||
|
export type { DropdownItem } from './Dropdown/Dropdown'
|
||||||
export { EventFeed } from './EventFeed/EventFeed'
|
export { EventFeed } from './EventFeed/EventFeed'
|
||||||
export { GroupCard } from './GroupCard/GroupCard'
|
export { GroupCard } from './GroupCard/GroupCard'
|
||||||
export { KpiStrip } from './KpiStrip/KpiStrip'
|
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||||
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||||
export type { FeedEvent } from './EventFeed/EventFeed'
|
export type { FeedEvent } from './EventFeed/EventFeed'
|
||||||
export { FilterBar } from './FilterBar/FilterBar'
|
export { FilterBar } from './FilterBar/FilterBar'
|
||||||
export { LineChart } from './LineChart/LineChart'
|
|
||||||
export { LogViewer } from './LogViewer/LogViewer'
|
export { LogViewer } from './LogViewer/LogViewer'
|
||||||
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
||||||
export { LoginDialog } from './LoginForm/LoginDialog'
|
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||||
@@ -32,12 +30,21 @@ 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'
|
||||||
|
|
||||||
|
// Charts — ThemedChart wrapper + Recharts re-exports
|
||||||
|
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||||
|
export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
|
||||||
|
export {
|
||||||
|
Line, Area, Bar,
|
||||||
|
ReferenceLine, ReferenceArea,
|
||||||
|
Legend, Brush,
|
||||||
|
} from 'recharts'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -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,18 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .collapseToggle {
|
||||||
|
top: 52px;
|
||||||
|
right: 50%;
|
||||||
|
transform: translateX(50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoImg {
|
.logoImg {
|
||||||
@@ -36,7 +78,8 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
margin-left: 4px;
|
margin-left: 8px;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search */
|
/* Search */
|
||||||
@@ -106,71 +149,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 +212,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 +222,8 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0 4px;
|
padding: 8px 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionChevronBtn {
|
.treeSectionChevronBtn {
|
||||||
@@ -239,7 +245,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionLabel {
|
.treeSectionLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
@@ -314,7 +320,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -346,7 +352,7 @@
|
|||||||
/* Badge */
|
/* Badge */
|
||||||
.treeBadge {
|
.treeBadge {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
@@ -383,92 +389,60 @@
|
|||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Starred section ─────────────────────────────────────────────────────── */
|
/* ── Section groups (top/bottom positioning) ───────────────────────────── */
|
||||||
|
|
||||||
.starredSection {
|
.sectionGroup {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
flex: 0 1 auto;
|
||||||
margin-top: 4px;
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.starredHeader {
|
.sectionGroup::-webkit-scrollbar {
|
||||||
color: var(--amber);
|
width: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.starredList {
|
.sectionGroup::-webkit-scrollbar-track {
|
||||||
padding: 0 6px 6px;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.starredGroup {
|
.sectionGroup::-webkit-scrollbar-thumb {
|
||||||
margin-bottom: 4px;
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.starredGroupLabel {
|
.sectionGroup::-webkit-scrollbar-thumb:hover {
|
||||||
padding: 4px 12px 2px;
|
background: rgba(255, 255, 255, 0.3);
|
||||||
font-size: 10px;
|
|
||||||
color: var(--sidebar-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.starredItem {
|
.sectionSpacer {
|
||||||
display: flex;
|
flex: 1 0 0;
|
||||||
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 {
|
/* ── Section content (scrollable maxHeight) ────────────────────────────── */
|
||||||
background: var(--sidebar-hover);
|
|
||||||
|
.sectionContent {
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.starredItemInfo {
|
.sectionContent::-webkit-scrollbar {
|
||||||
flex: 1;
|
width: 4px;
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.starredItemName {
|
.sectionContent::-webkit-scrollbar-track {
|
||||||
white-space: nowrap;
|
background: transparent;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.starredItemContext {
|
.sectionContent::-webkit-scrollbar-thumb {
|
||||||
font-size: 10px;
|
background: rgba(255, 255, 255, 0.15);
|
||||||
color: var(--sidebar-muted);
|
border-radius: 2px;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove button */
|
.sectionContent::-webkit-scrollbar-thumb:hover {
|
||||||
.starredRemove {
|
background: rgba(255, 255, 255, 0.3);
|
||||||
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 ────────────────────────────────────────────────────────── */
|
||||||
@@ -477,6 +451,7 @@
|
|||||||
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,479 @@
|
|||||||
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 () => {
|
// 17. renders sectionContent wrapper with maxHeight when open
|
||||||
const user = userEvent.setup()
|
it('renders section content wrapper with maxHeight style when open', () => {
|
||||||
renderSidebar()
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Apps"
|
||||||
|
open
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
maxHeight="200px"
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
const expandBtns = screen.getAllByLabelText('Expand')
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
await user.click(expandBtns[0])
|
expect(contentWrapper).toBeInTheDocument()
|
||||||
expect(screen.getByText('order-intake')).toBeInTheDocument()
|
expect(contentWrapper).toHaveStyle({ maxHeight: '200px' })
|
||||||
|
expect(screen.getByText('child')).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', () => {
|
// 18. renders sectionContent wrapper without maxHeight when not provided
|
||||||
renderSidebar()
|
it('renders section content wrapper without inline maxHeight when maxHeight is not provided', () => {
|
||||||
// payment-svc has no agents, so it shouldn't appear under the Agents section header
|
const { container } = render(
|
||||||
// But it still appears under Applications. Let's check the agent tree specifically.
|
<Wrapper>
|
||||||
const agentBadges = screen.queryAllByText(/\/.*live/)
|
<Sidebar>
|
||||||
// Only order-service should have an agent badge
|
<Sidebar.Section
|
||||||
expect(agentBadges).toHaveLength(1)
|
icon={<span>ic</span>}
|
||||||
expect(agentBadges[0].textContent).toBe('2/2 live')
|
label="Apps"
|
||||||
|
open
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
|
expect(contentWrapper).toBeInTheDocument()
|
||||||
|
expect(contentWrapper).not.toHaveStyle({ maxHeight: '200px' })
|
||||||
|
expect(screen.getByText('child')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 19. does not render sectionContent wrapper when section is closed
|
||||||
|
it('does not render section content wrapper when section is closed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Apps"
|
||||||
|
open={false}
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
maxHeight="200px"
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentWrapper = container.querySelector('.sectionContent')
|
||||||
|
expect(contentWrapper).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 20. renders top sections in sectionGroup wrapper
|
||||||
|
it('renders sections in top group wrapper by default', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const topGroup = container.querySelector('.sectionGroup')
|
||||||
|
expect(topGroup).toBeInTheDocument()
|
||||||
|
expect(topGroup!.textContent).toContain('Apps')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 21. renders bottom sections in separate group with spacer
|
||||||
|
it('renders bottom sections in a separate group with spacer between', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Routes" open onToggle={vi.fn()} position="bottom">
|
||||||
|
<div>routes content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = container.querySelectorAll('.sectionGroup')
|
||||||
|
expect(groups).toHaveLength(2)
|
||||||
|
expect(groups[0].textContent).toContain('Apps')
|
||||||
|
expect(groups[1].textContent).toContain('Routes')
|
||||||
|
|
||||||
|
const spacer = container.querySelector('.sectionSpacer')
|
||||||
|
expect(spacer).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 22. does not render spacer when no bottom sections
|
||||||
|
it('does not render spacer when all sections are top', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
|
||||||
|
<div>apps content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Agents" open onToggle={vi.fn()}>
|
||||||
|
<div>agents content</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const spacer = container.querySelector('.sectionSpacer')
|
||||||
|
expect(spacer).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 23. collapsed sidebar renders bottom sections in bottom group
|
||||||
|
it('renders bottom sections in bottom group when sidebar is collapsed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar collapsed onCollapseToggle={vi.fn()}>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Apps" open={false} onToggle={vi.fn()}>
|
||||||
|
<div>apps</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section icon={<span>ic</span>} label="Routes" open={false} onToggle={vi.fn()} position="bottom">
|
||||||
|
<div>routes</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = container.querySelectorAll('.sectionGroup')
|
||||||
|
expect(groups).toHaveLength(2)
|
||||||
|
|
||||||
|
// Bottom group should contain the Routes rail item
|
||||||
|
const bottomGroup = groups[1]
|
||||||
|
expect(bottomGroup.querySelector('[title="Routes"]')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,563 +1,300 @@
|
|||||||
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
|
||||||
|
position?: 'top' | 'bottom'
|
||||||
|
maxHeight?: 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── StarredGroup ─────────────────────────────────────────────────────────────
|
interface SidebarFooterLinkProps {
|
||||||
|
icon: ReactNode
|
||||||
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
|
||||||
<div className={styles.starredGroupLabel}>{label}</div>
|
className={`${styles.logo} ${className ?? ''}`}
|
||||||
{items.map((item) => (
|
onClick={onClick}
|
||||||
<div
|
style={onClick ? { cursor: 'pointer' } : undefined}
|
||||||
key={item.starKey}
|
role={onClick ? 'button' : undefined}
|
||||||
className={styles.starredItem}
|
tabIndex={onClick ? 0 : undefined}
|
||||||
onClick={() => onNavigate(item.path)}
|
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||||
role="button"
|
>
|
||||||
tabIndex={0}
|
{logo}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
|
{!collapsed && (
|
||||||
>
|
<div>
|
||||||
{item.icon}
|
<span className={styles.brand}>{title}</span>
|
||||||
<div className={styles.starredItemInfo}>
|
{version && <span className={styles.version}>{version}</span>}
|
||||||
<span className={styles.starredItemName}>{item.label}</span>
|
|
||||||
{item.parentApp && (
|
|
||||||
<span className={styles.starredItemContext}>{item.parentApp}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={styles.starredRemove}
|
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={`Remove ${item.label} from starred`}
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
function SidebarSection({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
position: _position,
|
||||||
|
maxHeight,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
const { collapsed, onCollapseToggle } = useSidebarContext()
|
||||||
|
|
||||||
export function Sidebar({ apps, className }: SidebarProps) {
|
// In icon-rail (collapsed) mode, render a centered icon with tooltip
|
||||||
const [search, setSearch] = useState('')
|
if (collapsed) {
|
||||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
return (
|
||||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
<div
|
||||||
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
|
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
|
||||||
|
title={label}
|
||||||
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
|
onClick={() => {
|
||||||
_setAppsCollapsed((prev) => {
|
// Expand sidebar and open the section
|
||||||
const next = updater(prev)
|
onCollapseToggle?.()
|
||||||
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
|
onToggle()
|
||||||
return next
|
}}
|
||||||
})
|
role="button"
|
||||||
}
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
_setAgentsCollapsed((prev) => {
|
onCollapseToggle?.()
|
||||||
const next = updater(prev)
|
onToggle()
|
||||||
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
|
}
|
||||||
return next
|
}}
|
||||||
})
|
>
|
||||||
}
|
<span className={styles.sectionIcon}>{icon}</span>
|
||||||
|
</div>
|
||||||
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}
|
||||||
</div>
|
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 && (
|
||||||
{/* Search */}
|
|
||||||
<div className={styles.searchWrap}>
|
|
||||||
<div className={styles.searchInner}>
|
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className={styles.searchInput}
|
|
||||||
type="text"
|
|
||||||
placeholder="Filter..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.searchClear}
|
|
||||||
onClick={() => setSearch('')}
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation (scrollable) — includes starred section */}
|
|
||||||
<div className={styles.navArea}>
|
|
||||||
<div className={styles.section}>Navigation</div>
|
|
||||||
|
|
||||||
{/* Applications tree (collapsible, label navigates to /apps) */}
|
|
||||||
<div className={styles.treeSection}>
|
|
||||||
<div className={styles.treeSectionToggle}>
|
|
||||||
<button
|
|
||||||
className={styles.treeSectionChevronBtn}
|
|
||||||
onClick={() => setAppsCollapsed((v) => !v)}
|
|
||||||
aria-expanded={!appsCollapsed}
|
|
||||||
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
|
||||||
>
|
|
||||||
{appsCollapsed ? '▸' : '▾'}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
|
||||||
onClick={() => navigate('/apps')}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
|
|
||||||
>
|
|
||||||
Applications
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!appsCollapsed && (
|
|
||||||
<SidebarTree
|
|
||||||
nodes={appNodes}
|
|
||||||
selectedPath={effectiveSelectedPath}
|
|
||||||
isStarred={isStarred}
|
|
||||||
onToggleStar={toggleStar}
|
|
||||||
filterQuery={search}
|
|
||||||
persistKey="cameleer:expanded:apps"
|
|
||||||
autoRevealPath={sidebarRevealPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Agents tree (collapsible, label navigates to /agents) */}
|
|
||||||
<div className={styles.treeSection}>
|
|
||||||
<div className={styles.treeSectionToggle}>
|
|
||||||
<button
|
|
||||||
className={styles.treeSectionChevronBtn}
|
|
||||||
onClick={() => setAgentsCollapsed((v) => !v)}
|
|
||||||
aria-expanded={!agentsCollapsed}
|
|
||||||
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
|
||||||
>
|
|
||||||
{agentsCollapsed ? '▸' : '▾'}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
|
||||||
onClick={() => navigate('/agents')}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
|
|
||||||
>
|
|
||||||
Agents
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!agentsCollapsed && (
|
|
||||||
<SidebarTree
|
|
||||||
nodes={agentNodes}
|
|
||||||
selectedPath={effectiveSelectedPath}
|
|
||||||
isStarred={isStarred}
|
|
||||||
onToggleStar={toggleStar}
|
|
||||||
filterQuery={search}
|
|
||||||
persistKey="cameleer:expanded:agents"
|
|
||||||
autoRevealPath={sidebarRevealPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Routes tree (collapsible, label navigates to /routes) */}
|
|
||||||
<div className={styles.treeSection}>
|
|
||||||
<div className={styles.treeSectionToggle}>
|
|
||||||
<button
|
|
||||||
className={styles.treeSectionChevronBtn}
|
|
||||||
onClick={() => setRoutesCollapsed((v) => !v)}
|
|
||||||
aria-expanded={!routesCollapsed}
|
|
||||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
|
||||||
>
|
|
||||||
{routesCollapsed ? '▸' : '▾'}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
|
||||||
onClick={() => navigate('/routes')}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
|
||||||
>
|
|
||||||
Routes
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!routesCollapsed && (
|
|
||||||
<SidebarTree
|
|
||||||
nodes={routeNodes}
|
|
||||||
selectedPath={effectiveSelectedPath}
|
|
||||||
isStarred={isStarred}
|
|
||||||
onToggleStar={toggleStar}
|
|
||||||
filterQuery={search}
|
|
||||||
persistKey="cameleer:expanded:routes"
|
|
||||||
autoRevealPath={sidebarRevealPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* No results message */}
|
|
||||||
{search && appNodes.length === 0 && agentNodes.length === 0 && (
|
|
||||||
<div className={styles.noResults}>No results</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Starred section (inside scrollable area, hidden when empty) */}
|
|
||||||
{hasStarred && (
|
|
||||||
<div className={styles.starredSection}>
|
|
||||||
<div className={styles.section}>
|
|
||||||
<span className={styles.starredHeader}>★ Starred</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.starredList}>
|
|
||||||
{starredApps.length > 0 && (
|
|
||||||
<StarredGroup
|
|
||||||
label="Applications"
|
|
||||||
items={starredApps}
|
|
||||||
onNavigate={navigate}
|
|
||||||
onRemove={toggleStar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{starredRoutes.length > 0 && (
|
|
||||||
<StarredGroup
|
|
||||||
label="Routes"
|
|
||||||
items={starredRoutes}
|
|
||||||
onNavigate={navigate}
|
|
||||||
onRemove={toggleStar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{starredAgents.length > 0 && (
|
|
||||||
<StarredGroup
|
|
||||||
label="Agents"
|
|
||||||
items={starredAgents}
|
|
||||||
onNavigate={navigate}
|
|
||||||
onRemove={toggleStar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{starredRouteStats.length > 0 && (
|
|
||||||
<StarredGroup
|
|
||||||
label="Routes"
|
|
||||||
items={starredRouteStats}
|
|
||||||
onNavigate={navigate}
|
|
||||||
onRemove={toggleStar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom links */}
|
|
||||||
<div className={styles.bottom}>
|
|
||||||
<div
|
<div
|
||||||
className={[
|
className={styles.sectionContent}
|
||||||
styles.bottomItem,
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
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>
|
{children}
|
||||||
<div className={styles.itemInfo}>
|
|
||||||
<div className={styles.itemName}>Admin</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
)}
|
||||||
className={[
|
</div>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 partitioned sections, then footer */}
|
||||||
|
{(() => {
|
||||||
|
const childArray = Children.toArray(children)
|
||||||
|
|
||||||
|
// Extract header
|
||||||
|
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
|
||||||
|
|
||||||
|
// Extract footer
|
||||||
|
const footerIdx = rest.findIndex(
|
||||||
|
(child) => isValidElement(child) && child.type === SidebarFooter,
|
||||||
|
)
|
||||||
|
const footer = footerIdx >= 0 ? rest[footerIdx] : null
|
||||||
|
const sections = footerIdx >= 0
|
||||||
|
? [...rest.slice(0, footerIdx), ...rest.slice(footerIdx + 1)]
|
||||||
|
: rest
|
||||||
|
|
||||||
|
// Partition sections into top/bottom by position prop
|
||||||
|
const topSections: typeof sections = []
|
||||||
|
const bottomSections: typeof sections = []
|
||||||
|
for (const child of sections) {
|
||||||
|
if (
|
||||||
|
isValidElement<SidebarSectionProps>(child) &&
|
||||||
|
child.props.position === 'bottom'
|
||||||
|
) {
|
||||||
|
bottomSections.push(child)
|
||||||
|
} else {
|
||||||
|
topSections.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBottomSections = bottomSections.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
{onSearchChange && !collapsed && (
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<div className={styles.searchInner}>
|
||||||
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
|
<Search size={12} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className={styles.searchInput}
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter..."
|
||||||
|
value={searchValue ?? ''}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => onSearchChange('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.sectionGroup}>
|
||||||
|
{topSections}
|
||||||
|
</div>
|
||||||
|
{hasBottomSections && <div className={styles.sectionSpacer} />}
|
||||||
|
{hasBottomSections && (
|
||||||
|
<div className={styles.sectionGroup}>
|
||||||
|
{bottomSections}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</aside>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compound export ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const Sidebar = Object.assign(SidebarRoot, {
|
||||||
|
Header: SidebarHeader,
|
||||||
|
Section: SidebarSection,
|
||||||
|
Footer: SidebarFooter,
|
||||||
|
FooterLink: SidebarFooterLink,
|
||||||
|
})
|
||||||
|
|||||||
14
src/design-system/layout/Sidebar/SidebarContext.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
export interface SidebarContextValue {
|
||||||
|
collapsed: boolean
|
||||||
|
onCollapseToggle?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarContext = createContext<SidebarContextValue>({
|
||||||
|
collapsed: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useSidebarContext(): SidebarContextValue {
|
||||||
|
return useContext(SidebarContext)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type MouseEvent,
|
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>
|
||||||
|
|||||||
22
src/design-system/layout/TopBar/AutoRefreshToggle.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import styles from './TopBar.module.css'
|
||||||
|
|
||||||
|
interface AutoRefreshToggleProps {
|
||||||
|
active: boolean
|
||||||
|
onChange: (active: boolean) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoRefreshToggle({ active, onChange, className }: AutoRefreshToggleProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${styles.liveToggle} ${active ? styles.liveToggleActive : ''} ${className ?? ''}`}
|
||||||
|
onClick={() => onChange(!active)}
|
||||||
|
type="button"
|
||||||
|
aria-label={active ? 'Disable auto-refresh' : 'Enable auto-refresh'}
|
||||||
|
title={active ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||||
|
>
|
||||||
|
<span className={styles.liveDot} />
|
||||||
|
{active ? 'AUTO' : 'MANUAL'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/design-system/layout/TopBar/SearchTrigger.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import styles from './TopBar.module.css'
|
||||||
|
|
||||||
|
interface SearchTriggerProps {
|
||||||
|
onClick: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchTrigger({ onClick, className }: SearchTriggerProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${styles.search} ${className ?? ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
aria-label="Open search"
|
||||||
|
>
|
||||||
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
|
<Search size={13} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||||
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
.kbd {
|
.kbd {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -153,16 +153,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.env {
|
.env {
|
||||||
font-family: var(--font-mono);
|
display: flex;
|
||||||
font-size: 10px;
|
align-items: center;
|
||||||
font-weight: 600;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--success-bg);
|
|
||||||
color: var(--success);
|
|
||||||
border: 1px solid var(--success-border);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user {
|
.user {
|
||||||
|
|||||||
@@ -1,101 +1,47 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
import { 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'
|
||||||
import { Avatar } from '../../primitives/Avatar/Avatar'
|
import { Avatar } from '../../primitives/Avatar/Avatar'
|
||||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
|
||||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
|
||||||
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
|
|
||||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
|
||||||
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[]
|
||||||
environment?: string
|
environment?: ReactNode
|
||||||
user?: { name: string }
|
user?: { name: string }
|
||||||
|
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
|
onNavigate?: (href: string) => void
|
||||||
className?: string
|
className?: string
|
||||||
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_ITEMS: ButtonGroupItem[] = [
|
|
||||||
{ value: 'completed', label: 'OK', color: 'var(--success)' },
|
|
||||||
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
|
|
||||||
{ value: 'failed', label: 'Error', color: 'var(--error)' },
|
|
||||||
{ value: 'running', label: 'Running', color: 'var(--running)' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
environment,
|
environment,
|
||||||
user,
|
user,
|
||||||
|
userMenuItems,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
onNavigate,
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const globalFilters = useGlobalFilters()
|
|
||||||
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} onNavigate={onNavigate} />
|
||||||
|
|
||||||
{/* Search trigger */}
|
{/* Center: consumer-provided controls */}
|
||||||
<button
|
{children}
|
||||||
className={styles.search}
|
|
||||||
onClick={() => commandPalette.setOpen(true)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Open search"
|
|
||||||
>
|
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
|
||||||
<span className={styles.kbd}>Ctrl+K</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Status filter group */}
|
{/* Right: theme toggle, env badge, user */}
|
||||||
<ButtonGroup
|
|
||||||
items={STATUS_ITEMS}
|
|
||||||
value={globalFilters.statusFilters}
|
|
||||||
onChange={(selected) => {
|
|
||||||
// Sync with global filter by toggling the diff
|
|
||||||
const current = globalFilters.statusFilters
|
|
||||||
for (const v of selected) {
|
|
||||||
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
|
||||||
}
|
|
||||||
for (const v of current) {
|
|
||||||
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Time range pills */}
|
|
||||||
<TimeRangeDropdown
|
|
||||||
value={globalFilters.timeRange}
|
|
||||||
onChange={globalFilters.setTimeRange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
|
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
<button
|
|
||||||
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
|
|
||||||
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
|
|
||||||
type="button"
|
|
||||||
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
|
|
||||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
|
||||||
>
|
|
||||||
<span className={styles.liveDot} />
|
|
||||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className={styles.themeToggle}
|
className={styles.themeToggle}
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
@@ -103,10 +49,10 @@ 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>
|
<div className={styles.env}>{environment}</div>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -117,7 +63,9 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
|
...(userMenuItems ?? []),
|
||||||
|
...(userMenuItems?.length ? [{ label: '', divider: true }] : []),
|
||||||
|
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
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'
|
||||||
|
export { SearchTrigger } from './TopBar/SearchTrigger'
|
||||||
|
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sm { width: 24px; height: 24px; font-size: 9px; }
|
.sm { width: 24px; height: 24px; font-size: 9px; }
|
||||||
.md { width: 28px; height: 28px; font-size: 11px; }
|
.md { width: 28px; height: 28px; font-size: 12px; }
|
||||||
.lg { width: 40px; height: 40px; font-size: 14px; }
|
.lg { width: 40px; height: 40px; font-size: 14px; }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
.sm { padding: 4px 10px; font-size: 11px; }
|
.sm { padding: 4px 10px; font-size: 12px; }
|
||||||
.md { padding: 6px 14px; font-size: 12px; }
|
.md { padding: 6px 14px; font-size: 12px; }
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
|
|||||||