Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist/
|
|||||||
test-results/
|
test-results/
|
||||||
screenshots/
|
screenshots/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
.gitnexus
|
||||||
|
|||||||
119
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'
|
||||||
|
|
||||||
@@ -110,4 +118,115 @@ 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** (1461 symbols, 2336 relationships, 23 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**
|
||||||
@@ -99,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
|
||||||
@@ -227,7 +246,7 @@ import {
|
|||||||
| 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 |
|
||||||
@@ -284,8 +303,10 @@ import {
|
|||||||
| 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
|
||||||
|
|
||||||
@@ -296,6 +317,10 @@ import { Button, Input, Badge } from './design-system/primitives'
|
|||||||
import { DataTable, Modal, Toast } from './design-system/composites'
|
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||||
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||||
import { AppShell } from './design-system/layout/AppShell'
|
import { AppShell } from './design-system/layout/AppShell'
|
||||||
|
import { Sidebar } from './design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { SidebarTree } from './design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import type { SidebarTreeNode } from './design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import { useStarred } from './design-system/layout/Sidebar/useStarred'
|
||||||
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -309,6 +334,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/cameleer3-16.png
Normal file
|
After Width: | Height: | Size: 941 B |
BIN
assets/cameleer3-180.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/cameleer3-192.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/cameleer3-32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
assets/cameleer3-48.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
assets/cameleer3-512.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
112
assets/cameleer3-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
1609
docs/superpowers/plans/2026-04-02-composable-sidebar.md
Normal file
399
docs/superpowers/specs/2026-04-02-composable-sidebar-design.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# Composable Sidebar Refactor
|
||||||
|
|
||||||
|
**Date:** 2026-04-02
|
||||||
|
**Upstream issue:** cameleer3-server #112
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The current `Sidebar` component is monolithic. It hardcodes three navigation sections (Applications, Agents, Routes), a starred items section, bottom links (Admin, API Docs), and all tree-building logic (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`). The consuming application can only pass `SidebarApp[]` data — it cannot control what sections exist, what order they appear in, or add new sections without modifying this package.
|
||||||
|
|
||||||
|
This blocks two features the consuming application needs:
|
||||||
|
1. **Admin accordion** — when the user enters admin context, the sidebar should expand an Admin section and collapse operational sections, all controlled by the application
|
||||||
|
2. **Icon-rail collapse** — the sidebar should collapse to a narrow icon strip, like modern app sidebars (Linear, VS Code, etc.)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Refactor `Sidebar` into a composable compound component. The DS provides the frame and building blocks. The consuming application controls all content.
|
||||||
|
|
||||||
|
## Current Exports (to be replaced)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Current — monolithic
|
||||||
|
export { Sidebar } from './Sidebar/Sidebar'
|
||||||
|
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Exports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// New — composable
|
||||||
|
export { Sidebar } from './Sidebar/Sidebar'
|
||||||
|
export { SidebarTree } from './Sidebar/SidebarTree'
|
||||||
|
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||||
|
export { useStarred } from './Sidebar/useStarred'
|
||||||
|
```
|
||||||
|
|
||||||
|
`SidebarApp`, `SidebarRoute`, `SidebarAgent` types are removed — they are application-domain types that move to the consuming app.
|
||||||
|
|
||||||
|
## Compound Component API
|
||||||
|
|
||||||
|
### `<Sidebar>`
|
||||||
|
|
||||||
|
The outer shell. Renders the sidebar frame with an optional search input and collapse toggle.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar
|
||||||
|
collapsed={false}
|
||||||
|
onCollapseToggle={() => {}}
|
||||||
|
searchValue=""
|
||||||
|
onSearchChange={(query) => {}}
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
<Sidebar.Header ... />
|
||||||
|
<Sidebar.Section ... />
|
||||||
|
<Sidebar.Section ... />
|
||||||
|
<Sidebar.Footer ... />
|
||||||
|
</Sidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `collapsed` | `boolean` | `false` | Render as ~48px icon rail |
|
||||||
|
| `onCollapseToggle` | `() => void` | - | Collapse/expand toggle clicked |
|
||||||
|
| `onSearchChange` | `(query: string) => void` | - | Search input changed. Omit to hide search. |
|
||||||
|
| `searchValue` | `string` | `''` | Controlled value for the search input |
|
||||||
|
| `children` | `ReactNode` | - | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
|
||||||
|
| `className` | `string` | - | Additional CSS class |
|
||||||
|
|
||||||
|
**Search state ownership:** The DS renders the search input as a dumb controlled input and calls `onSearchChange` on every keystroke. The consuming application owns the search state and passes it to each `SidebarTree` as `filterQuery`. This lets the app control filtering behavior (e.g., clear search when switching sections, filter only certain sections). The DS does not hold any search state internally.
|
||||||
|
|
||||||
|
**Rendering rules:**
|
||||||
|
- Expanded: full width (~260px), all content visible
|
||||||
|
- Collapsed: ~48px wide, only icons visible, tooltips on hover
|
||||||
|
- Width transition: `transition: width 200ms ease`
|
||||||
|
- Collapse toggle button (`<<` / `>>` chevron) in top-right corner
|
||||||
|
- Search input hidden when collapsed
|
||||||
|
- Search input auto-positioned between `Sidebar.Header` and first `Sidebar.Section` (not above Header)
|
||||||
|
|
||||||
|
### `<Sidebar.Header>`
|
||||||
|
|
||||||
|
Logo, title, and version. In collapsed mode, renders only the logo centered.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Header
|
||||||
|
logo={<img src="..." />}
|
||||||
|
title="cameleer"
|
||||||
|
version="v3.2.1"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `logo` | `ReactNode` | - | Logo element |
|
||||||
|
| `title` | `string` | - | App name (hidden when collapsed) |
|
||||||
|
| `version` | `string` | - | Version text (hidden when collapsed) |
|
||||||
|
|
||||||
|
### `<Sidebar.Section>`
|
||||||
|
|
||||||
|
An accordion section with a collapsible header and content area.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Section
|
||||||
|
label="APPLICATIONS"
|
||||||
|
icon={<Box size={14} />}
|
||||||
|
collapsed={false}
|
||||||
|
onToggle={() => {}}
|
||||||
|
active={false}
|
||||||
|
>
|
||||||
|
<SidebarTree nodes={nodes} ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `label` | `string` | - | Section header text (rendered uppercase via CSS) |
|
||||||
|
| `icon` | `ReactNode` | - | Icon for header and collapsed rail |
|
||||||
|
| `collapsed` | `boolean` | `false` | Whether children are hidden |
|
||||||
|
| `onToggle` | `() => void` | - | Header clicked |
|
||||||
|
| `children` | `ReactNode` | - | Content when expanded |
|
||||||
|
| `active` | `boolean` | - | Override active highlight. If omitted, not highlighted. |
|
||||||
|
|
||||||
|
**Expanded rendering:**
|
||||||
|
```
|
||||||
|
[icon] APPLICATIONS
|
||||||
|
(children rendered here)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Collapsed rendering:**
|
||||||
|
```
|
||||||
|
[icon] APPLICATIONS
|
||||||
|
```
|
||||||
|
|
||||||
|
**In sidebar icon-rail mode:**
|
||||||
|
```
|
||||||
|
[icon] <- centered, tooltip shows label on hover
|
||||||
|
```
|
||||||
|
|
||||||
|
Header has: icon and label (no chevron — the entire row is clickable). Active section gets the amber left-border accent (existing pattern). Clicking anywhere on the header row calls `onToggle`.
|
||||||
|
|
||||||
|
**Implementation detail:** `Sidebar.Section` and `Sidebar.Header` need to know the parent's `collapsed` state to switch between expanded and icon-rail rendering. The `<Sidebar>` component provides `collapsed` and `onCollapseToggle` via React context (`SidebarContext`). Sub-components read from context — no prop drilling needed.
|
||||||
|
|
||||||
|
**Icon-rail click behavior:** In collapsed mode, clicking a section icon fires both `onCollapseToggle` and `onToggle` simultaneously on the same click. The sidebar expands and the section opens in one motion. No navigation occurs — the user is expanding the sidebar to see what's inside, not committing to a destination. They click a tree item after the section is visible to navigate.
|
||||||
|
|
||||||
|
### `<Sidebar.Footer>`
|
||||||
|
|
||||||
|
Pinned to the bottom of the sidebar. Container for `Sidebar.FooterLink` items.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => {}} />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
In collapsed mode, footer links render as centered icons with tooltips.
|
||||||
|
|
||||||
|
### `<Sidebar.FooterLink>`
|
||||||
|
|
||||||
|
A single bottom link.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `icon` | `ReactNode` | - | Link icon |
|
||||||
|
| `label` | `string` | - | Link text (hidden when collapsed, shown as tooltip) |
|
||||||
|
| `onClick` | `() => void` | - | Click handler |
|
||||||
|
| `active` | `boolean` | `false` | Active state highlight |
|
||||||
|
|
||||||
|
### `<SidebarTree>` (no changes, newly exported)
|
||||||
|
|
||||||
|
Already exists at `Sidebar/SidebarTree.tsx`. No modifications needed — it already accepts all data via props. Just export it from the package.
|
||||||
|
|
||||||
|
**Current props (unchanged):**
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `nodes` | `SidebarTreeNode[]` | Tree data |
|
||||||
|
| `selectedPath` | `string` | Currently active path for highlighting |
|
||||||
|
| `filterQuery` | `string` | Search filter text |
|
||||||
|
| `onNavigate` | `(path: string) => void` | Navigation callback |
|
||||||
|
| `persistKey` | `string` | localStorage key for expand state |
|
||||||
|
| `autoRevealPath` | `string \| null` | Path to auto-expand to |
|
||||||
|
| `isStarred` | `(id: string) => boolean` | Star state checker |
|
||||||
|
| `onToggleStar` | `(id: string) => void` | Star toggle callback |
|
||||||
|
|
||||||
|
### `useStarred` hook (no changes, newly exported)
|
||||||
|
|
||||||
|
Already exists at `Sidebar/useStarred.ts`. Export as-is.
|
||||||
|
|
||||||
|
**Returns:** `{ starredIds, isStarred, toggleStar }`
|
||||||
|
|
||||||
|
## What Gets Removed
|
||||||
|
|
||||||
|
All of this application-specific logic is deleted from the DS:
|
||||||
|
|
||||||
|
1. **`buildAppTreeNodes()`** (~30 lines) — transforms `SidebarApp[]` into `SidebarTreeNode[]`
|
||||||
|
2. **`buildRouteTreeNodes()`** (~20 lines) — transforms apps into route tree nodes
|
||||||
|
3. **`buildAgentTreeNodes()`** (~25 lines) — transforms apps into agent tree nodes with live-count badges
|
||||||
|
4. **`collectStarredItems()`** (~20 lines) — gathers starred items across types
|
||||||
|
5. **`StarredGroup`** sub-component (~30 lines) — renders grouped starred items
|
||||||
|
6. **Hardcoded sections** (~100 lines) — Applications, Agents, Routes section rendering with localStorage persistence
|
||||||
|
7. **Hardcoded bottom links** (~30 lines) — Admin and API Docs links
|
||||||
|
8. **Auto-reveal effect** (~20 lines) — `sidebarRevealPath` effect
|
||||||
|
9. **`SidebarApp`, `SidebarRoute`, `SidebarAgent` types** — domain types, not DS types
|
||||||
|
10. **`formatCount()` helper** — number formatting, moves to consuming app
|
||||||
|
|
||||||
|
Total: ~300 lines of application logic removed, replaced by ~150 lines of compound component shell.
|
||||||
|
|
||||||
|
## CSS Changes
|
||||||
|
|
||||||
|
### New styles needed
|
||||||
|
|
||||||
|
- `.sidebarCollapsed` — narrow width (48px), centered icons
|
||||||
|
- `.collapseToggle` — `<<` / `>>` button positioning
|
||||||
|
- `.sectionIcon` — icon rendering in section headers
|
||||||
|
- `.tooltip` — hover tooltips for collapsed mode
|
||||||
|
- Width transition: `transition: width 200ms ease` on `.sidebar`
|
||||||
|
|
||||||
|
### Styles that stay
|
||||||
|
|
||||||
|
- `.sidebar` (modified: width becomes conditional)
|
||||||
|
- `.searchWrap`, `.searchInput` (unchanged)
|
||||||
|
- `.navArea` (unchanged)
|
||||||
|
- All tree styles in `SidebarTree` (unchanged)
|
||||||
|
|
||||||
|
### Styles removed
|
||||||
|
|
||||||
|
- `.bottom`, `.bottomItem`, `.bottomItemActive` — replaced by `Sidebar.Footer` / `Sidebar.FooterLink` styles
|
||||||
|
- `.starredSection`, `.starredGroup`, `.starredItem`, `.starredRemove` — starred rendering moves to app
|
||||||
|
- `.section` — replaced by `Sidebar.Section` styles
|
||||||
|
|
||||||
|
## File Structure After Refactor
|
||||||
|
|
||||||
|
```
|
||||||
|
Sidebar/
|
||||||
|
├── Sidebar.tsx # Compound component: Sidebar, Sidebar.Header,
|
||||||
|
│ # Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink
|
||||||
|
├── Sidebar.module.css # Updated styles (shell + section + footer + collapsed)
|
||||||
|
├── SidebarTree.tsx # Unchanged
|
||||||
|
├── SidebarTree.module.css # Unchanged (if separate, otherwise stays in Sidebar.module.css)
|
||||||
|
├── useStarred.ts # Unchanged
|
||||||
|
├── useStarred.test.ts # Unchanged
|
||||||
|
└── Sidebar.test.tsx # Updated for new compound API
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Update `Sidebar.test.tsx` to test the compound component API:
|
||||||
|
|
||||||
|
- Renders Header with logo, title, version
|
||||||
|
- Renders Sections with labels and icons
|
||||||
|
- Section toggle calls `onToggle`
|
||||||
|
- Collapsed sections hide children
|
||||||
|
- Sidebar collapsed mode renders icon rail
|
||||||
|
- Collapse toggle calls `onCollapseToggle`
|
||||||
|
- Footer links render with icons and labels
|
||||||
|
- Collapsed mode hides labels, shows tooltips
|
||||||
|
- Search input calls `onSearchChange`
|
||||||
|
- Search hidden when sidebar collapsed
|
||||||
|
- Section icon click in collapsed mode calls both `onCollapseToggle` and `onToggle`
|
||||||
|
|
||||||
|
`SidebarTree` tests are unaffected.
|
||||||
|
|
||||||
|
## Usage Example (for reference)
|
||||||
|
|
||||||
|
This is how the consuming application (cameleer3-server) will use the new API. This code does NOT live in the design system — it's shown for context only.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In LayoutShell.tsx (consuming app)
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [filterQuery, setFilterQuery] = useState('');
|
||||||
|
const [appsCollapsed, setAppsCollapsed] = useState(false);
|
||||||
|
const [agentsCollapsed, setAgentsCollapsed] = useState(false);
|
||||||
|
const [routesCollapsed, setRoutesCollapsed] = useState(true);
|
||||||
|
const [adminCollapsed, setAdminCollapsed] = useState(true);
|
||||||
|
|
||||||
|
// Accordion: entering admin expands admin, collapses others
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdminPage) {
|
||||||
|
setAdminCollapsed(false);
|
||||||
|
setAppsCollapsed(true);
|
||||||
|
setAgentsCollapsed(true);
|
||||||
|
setRoutesCollapsed(true);
|
||||||
|
} else {
|
||||||
|
setAdminCollapsed(true);
|
||||||
|
// restore previous operational states
|
||||||
|
}
|
||||||
|
}, [isAdminPage]);
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
|
||||||
|
searchValue={filterQuery}
|
||||||
|
onSearchChange={setFilterQuery}
|
||||||
|
>
|
||||||
|
<Sidebar.Header logo={<CameleerLogo />} title="cameleer" version="v3.2.1" />
|
||||||
|
|
||||||
|
{isAdminPage && (
|
||||||
|
<Sidebar.Section label="ADMIN" icon={<Settings size={14} />}
|
||||||
|
collapsed={adminCollapsed} onToggle={() => setAdminCollapsed(v => !v)}>
|
||||||
|
<SidebarTree nodes={adminNodes} ... filterQuery={filterQuery} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Sidebar.Section label="APPLICATIONS" icon={<Box size={14} />}
|
||||||
|
collapsed={appsCollapsed} onToggle={() => { setAppsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||||
|
<SidebarTree nodes={appNodes} ... filterQuery={filterQuery} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="AGENTS" icon={<Cpu size={14} />}
|
||||||
|
collapsed={agentsCollapsed} onToggle={() => { setAgentsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||||
|
<SidebarTree nodes={agentNodes} ... filterQuery={filterQuery} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section label="ROUTES" icon={<GitBranch size={14} />}
|
||||||
|
collapsed={routesCollapsed} onToggle={() => { setRoutesCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
|
||||||
|
<SidebarTree nodes={routeNodes} ... filterQuery={filterQuery} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mock App Migration — LayoutShell
|
||||||
|
|
||||||
|
The 11 page files currently duplicating `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>` will be consolidated into a single `LayoutShell` component.
|
||||||
|
|
||||||
|
### `src/layout/LayoutShell.tsx`
|
||||||
|
|
||||||
|
Composes the sidebar once using the new compound API. All page-specific content is rendered via `<Outlet />`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/layout/LayoutShell.tsx
|
||||||
|
export function LayoutShell() {
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
const [appsCollapsed, setAppsCollapsed] = useState(false)
|
||||||
|
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
|
||||||
|
const [routesCollapsed, setRoutesCollapsed] = useState(false)
|
||||||
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
|
const location = useLocation()
|
||||||
|
// ... build tree nodes from SIDEBAR_APPS, starred section, etc.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
sidebar={
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
|
||||||
|
searchValue={filterQuery}
|
||||||
|
onSearchChange={setFilterQuery}
|
||||||
|
>
|
||||||
|
<Sidebar.Header logo={...} title="cameleer" version="v3.2.1" />
|
||||||
|
<Sidebar.Section label="Applications" icon={...}
|
||||||
|
collapsed={appsCollapsed} onToggle={() => setAppsCollapsed(v => !v)}>
|
||||||
|
<SidebarTree nodes={appNodes} filterQuery={filterQuery} ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section label="Agents" icon={...}
|
||||||
|
collapsed={agentsCollapsed} onToggle={() => setAgentsCollapsed(v => !v)}>
|
||||||
|
<SidebarTree nodes={agentNodes} filterQuery={filterQuery} ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Section label="Routes" icon={...}
|
||||||
|
collapsed={routesCollapsed} onToggle={() => setRoutesCollapsed(v => !v)}>
|
||||||
|
<SidebarTree nodes={routeNodes} filterQuery={filterQuery} ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
{/* Starred section built from useStarred + SIDEBAR_APPS */}
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={...} label="Admin" ... />
|
||||||
|
<Sidebar.FooterLink icon={...} label="API Docs" ... />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route structure change
|
||||||
|
|
||||||
|
`App.tsx` switches from per-page `<Route element={<Page />}>` to a layout route:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Route element={<LayoutShell />}>
|
||||||
|
<Route path="/apps" element={<Dashboard />} />
|
||||||
|
<Route path="/apps/:id" element={<Dashboard />} />
|
||||||
|
...all existing routes...
|
||||||
|
</Route>
|
||||||
|
```
|
||||||
|
|
||||||
|
All tree-building helpers (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`), starred section logic (`collectStarredItems`, `StarredGroup`), `formatCount`, and `sidebarRevealPath` handling move from `Sidebar.tsx` into `LayoutShell.tsx`. Each page file loses its `<AppShell sidebar={...}>` wrapper and becomes just the page content.
|
||||||
|
|
||||||
|
The Inventory page's `LayoutSection` keeps its own inline `<Sidebar>` demo with `SAMPLE_APPS` data — it's a showcase, not a navigation shell.
|
||||||
|
|
||||||
|
## Breaking Change
|
||||||
|
|
||||||
|
This is a **breaking change** to the `Sidebar` API. The old `<Sidebar apps={[...]} onNavigate={...} />` signature is removed entirely. The consuming application must migrate to the compound component API in the same release cycle.
|
||||||
|
|
||||||
|
Coordinate: bump DS version, update server UI, deploy together.
|
||||||
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.6",
|
"version": "0.1.40",
|
||||||
"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
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 |
@@ -72,7 +72,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +130,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.legendLabel {
|
.legendLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Axis labels */
|
/* Axis labels */
|
||||||
.yLabel {
|
.yLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
writing-mode: vertical-lr;
|
writing-mode: vertical-lr;
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
|
|
||||||
.xLabel {
|
.xLabel {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding-left: 48px;
|
padding-left: 48px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltipRow {
|
.tooltipRow {
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +117,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.legendLabel {
|
.legendLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yLabel {
|
.yLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
writing-mode: vertical-lr;
|
writing-mode: vertical-lr;
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
|
|
||||||
.xLabel {
|
.xLabel {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding-left: 48px;
|
padding-left: 48px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ export function BarChart({
|
|||||||
setTooltip({ x: mx, y: my, label: catLabel, values })
|
setTooltip({ x: mx, y: my, label: catLabel, values })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showBarTooltip(e: React.MouseEvent<SVGRectElement>, cat: string) {
|
||||||
|
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
||||||
|
handleMouseEnter(
|
||||||
|
cat,
|
||||||
|
e.clientX - rect.left,
|
||||||
|
e.clientY - rect.top,
|
||||||
|
series.map((ss, ssi) => ({
|
||||||
|
series: ss.label,
|
||||||
|
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
||||||
|
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
||||||
@@ -138,19 +152,7 @@ export function BarChart({
|
|||||||
height={barH}
|
height={barH}
|
||||||
fill={color}
|
fill={color}
|
||||||
className={styles.bar}
|
className={styles.bar}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
||||||
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - rect.left,
|
|
||||||
e.clientY - rect.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -184,19 +186,7 @@ export function BarChart({
|
|||||||
height={barH}
|
height={barH}
|
||||||
fill={color}
|
fill={color}
|
||||||
className={styles.bar}
|
className={styles.bar}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
||||||
const svgEl = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - svgEl.left,
|
|
||||||
e.clientY - svgEl.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
.sep {
|
.sep {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
|
|
||||||
/* Match context snippet */
|
/* Match context snippet */
|
||||||
.matchContext {
|
.matchContext {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
@@ -316,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ interface CommandPaletteProps {
|
|||||||
onSubmit?: (query: string) => void
|
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',
|
attribute: 'Attributes',
|
||||||
@@ -28,8 +27,8 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
|||||||
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',
|
'attribute',
|
||||||
@@ -37,6 +36,13 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
|||||||
'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
|
||||||
|
|
||||||
@@ -69,7 +75,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
|||||||
|
|
||||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: 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)
|
||||||
@@ -128,7 +134,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
|
|
||||||
// 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)
|
||||||
@@ -148,6 +154,19 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
return counts
|
return counts
|
||||||
}, [queryFiltered])
|
}, [queryFiltered])
|
||||||
|
|
||||||
|
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
|
||||||
|
const visibleCategories = useMemo(() => {
|
||||||
|
const dataCategories = new Set(data.map((r) => r.category))
|
||||||
|
const tabs: string[] = ['all']
|
||||||
|
for (const cat of KNOWN_CATEGORY_ORDER) {
|
||||||
|
if (dataCategories.has(cat)) tabs.push(cat)
|
||||||
|
}
|
||||||
|
for (const cat of dataCategories) {
|
||||||
|
if (!tabs.includes(cat)) tabs.push(cat)
|
||||||
|
}
|
||||||
|
return tabs
|
||||||
|
}, [data])
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
@@ -186,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
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()}
|
||||||
@@ -233,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
|
|
||||||
{/* 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"
|
||||||
@@ -249,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
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>
|
||||||
)}
|
)}
|
||||||
@@ -270,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
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)
|
||||||
@@ -293,6 +325,12 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
onSelect(result)
|
onSelect(result)
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
onSelect(result)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
||||||
>
|
>
|
||||||
<div className={styles.itemMain}>
|
<div className={styles.itemMain}>
|
||||||
@@ -328,10 +366,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
{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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'application' | 'exchange' | 'attribute' | '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
|
||||||
|
|||||||
@@ -42,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;
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
|
|
||||||
.rangeInfo {
|
.rangeInfo {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginationRight {
|
.paginationRight {
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pageSizeLabel {
|
.pageSizeLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,11 +120,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.legendLabel {
|
.legendLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yLabel {
|
.yLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
writing-mode: vertical-lr;
|
writing-mode: vertical-lr;
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
|
|
||||||
.xLabel {
|
.xLabel {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding-left: 48px;
|
padding-left: 48px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
|||||||
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-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 { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
import { formatDuration } from '../../../utils/format-utils'
|
||||||
|
|
||||||
export interface NodeBadge {
|
export interface NodeBadge {
|
||||||
label: string
|
label: string
|
||||||
@@ -42,12 +43,6 @@ interface RouteFlowProps {
|
|||||||
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -57,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)
|
||||||
@@ -71,10 +75,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Remove after animation completes
|
// Remove after animation completes
|
||||||
setTimeout(() => {
|
setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
}, [removeToast])
|
||||||
}, EXIT_ANIMATION_MS)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toast = useCallback(
|
const toast = useCallback(
|
||||||
(options: ToastOptions): string => {
|
(options: ToastOptions): string => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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'
|
||||||
|
|||||||
@@ -5,6 +5,36 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: width 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse toggle */
|
||||||
|
.collapseToggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 4px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--sidebar-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0;
|
||||||
|
z-index: 1;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseToggle:hover {
|
||||||
|
color: var(--sidebar-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Logo */
|
/* Logo */
|
||||||
@@ -15,6 +45,12 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .logo {
|
||||||
|
padding: 16px 0;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoImg {
|
.logoImg {
|
||||||
@@ -34,7 +70,7 @@
|
|||||||
|
|
||||||
.version {
|
.version {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
@@ -106,71 +142,40 @@
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollable nav area */
|
/* Section icon (collapsed rail) */
|
||||||
.navArea {
|
.sectionIcon {
|
||||||
flex: 1;
|
display: flex;
|
||||||
overflow-y: auto;
|
align-items: center;
|
||||||
min-height: 0;
|
justify-content: center;
|
||||||
}
|
width: 16px;
|
||||||
|
|
||||||
/* Section headers */
|
|
||||||
.section {
|
|
||||||
padding: 14px 12px 5px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Items container */
|
/* Rail item (collapsed sidebar section) */
|
||||||
.items {
|
.sectionRailItem {
|
||||||
padding: 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nav item (flat links like Dashboards) */
|
|
||||||
.item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
padding: 7px 12px;
|
padding: 10px 0;
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--sidebar-text);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.12s;
|
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
margin-bottom: 1px;
|
transition: background 0.12s;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover {
|
.sectionRailItem:hover {
|
||||||
background: var(--sidebar-hover);
|
background: var(--sidebar-hover);
|
||||||
color: #e8dfd4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item.active {
|
.sectionRailItemActive {
|
||||||
background: var(--sidebar-active);
|
|
||||||
color: var(--amber);
|
|
||||||
border-left-color: var(--amber);
|
border-left-color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navIcon {
|
.sectionRailItemActive .sectionIcon {
|
||||||
font-size: 14px;
|
|
||||||
width: 18px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--sidebar-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item.active .navIcon {
|
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.routeArrow {
|
.treeSectionActive {
|
||||||
color: var(--sidebar-muted);
|
border-left-color: var(--amber);
|
||||||
font-size: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Item sub-elements */
|
/* Item sub-elements */
|
||||||
@@ -200,15 +205,7 @@
|
|||||||
padding: 0 6px 6px;
|
padding: 0 6px 6px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
border-left: 3px solid transparent;
|
||||||
|
|
||||||
.treeSectionLabel {
|
|
||||||
padding: 10px 12px 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: var(--sidebar-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapsible section toggle */
|
/* Collapsible section toggle */
|
||||||
@@ -218,6 +215,8 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0 4px;
|
padding: 8px 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionChevronBtn {
|
.treeSectionChevronBtn {
|
||||||
@@ -239,7 +238,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 +313,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 +345,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,100 +382,13 @@
|
|||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Starred section ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.starredSection {
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredHeader {
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredList {
|
|
||||||
padding: 0 6px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredGroup {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredGroupLabel {
|
|
||||||
padding: 4px 12px 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--sidebar-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--sidebar-text);
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.12s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredItem:hover {
|
|
||||||
background: var(--sidebar-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredItemInfo {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredItemName {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredItemContext {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--sidebar-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove button */
|
|
||||||
.starredRemove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 2px;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--sidebar-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s, color 0.15s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredItem:hover .starredRemove {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.starredRemove:hover {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bottom links ────────────────────────────────────────────────────────── */
|
/* ── Bottom links ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomItem {
|
.bottomItem {
|
||||||
|
|||||||
@@ -1,172 +1,327 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { Sidebar, type SidebarApp } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import { ThemeProvider } from '../../providers/ThemeProvider'
|
import { ThemeProvider } from '../../providers/ThemeProvider'
|
||||||
|
|
||||||
const TEST_APPS: SidebarApp[] = [
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
{
|
|
||||||
id: 'order-service',
|
|
||||||
name: 'order-service',
|
|
||||||
health: 'live',
|
|
||||||
exchangeCount: 1433,
|
|
||||||
routes: [
|
|
||||||
{ id: 'order-intake', name: 'order-intake', exchangeCount: 892 },
|
|
||||||
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
|
|
||||||
],
|
|
||||||
agents: [
|
|
||||||
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: 14.2 },
|
|
||||||
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: 11.8 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'payment-svc',
|
|
||||||
name: 'payment-svc',
|
|
||||||
health: 'live',
|
|
||||||
exchangeCount: 912,
|
|
||||||
routes: [
|
|
||||||
{ id: 'payment-process', name: 'payment-process', exchangeCount: 414 },
|
|
||||||
],
|
|
||||||
agents: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) {
|
const LogoIcon = () => <svg data-testid="logo-icon" />
|
||||||
return render(
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
<Sidebar apps={TEST_APPS} {...props} />
|
</ThemeProvider>
|
||||||
</MemoryRouter>
|
|
||||||
</ThemeProvider>,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Sidebar', () => {
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
describe('Sidebar compound component', () => {
|
||||||
sessionStorage.clear()
|
// 1. renders Header with logo, title, version
|
||||||
|
it('renders Header with logo, title, and version', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('MyApp')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('v1.2.3')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the logo and brand name', () => {
|
// 2. hides Header title and version when collapsed
|
||||||
renderSidebar()
|
it('hides Header title and version when sidebar is collapsed', () => {
|
||||||
expect(screen.getByText('cameleer')).toBeInTheDocument()
|
render(
|
||||||
expect(screen.getByText('v3.2.1')).toBeInTheDocument()
|
<Wrapper>
|
||||||
|
<Sidebar collapsed>
|
||||||
|
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('MyApp')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('v1.2.3')).not.toBeInTheDocument()
|
||||||
|
// Logo should still be visible
|
||||||
|
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the search input', () => {
|
// 3. renders Section with label and children
|
||||||
renderSidebar()
|
it('renders Section with label and children when open', () => {
|
||||||
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument()
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>icon</span>}
|
||||||
|
label="Settings"
|
||||||
|
open
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>Section Child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Section Child')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders Navigation section header', () => {
|
// 4. hides Section children when section collapsed (open=false)
|
||||||
renderSidebar()
|
it('hides Section children when section is not open', () => {
|
||||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>icon</span>}
|
||||||
|
label="Settings"
|
||||||
|
open={false}
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>Section Child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Section Child')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders Applications tree section', () => {
|
// 5. calls onToggle when Section header clicked
|
||||||
renderSidebar()
|
it('calls onToggle when Section chevron button is clicked', async () => {
|
||||||
expect(screen.getByText('Applications')).toBeInTheDocument()
|
const user = userEvent.setup()
|
||||||
|
const onToggle = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>icon</span>}
|
||||||
|
label="Settings"
|
||||||
|
open
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /collapse settings/i })
|
||||||
|
await user.click(btn)
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders Agents tree section', () => {
|
// 6. renders collapse toggle and calls onCollapseToggle
|
||||||
renderSidebar()
|
it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
|
||||||
expect(screen.getByText('Agents')).toBeInTheDocument()
|
const user = userEvent.setup()
|
||||||
|
const onCollapseToggle = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar onCollapseToggle={onCollapseToggle}>
|
||||||
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i })
|
||||||
|
await user.click(toggleBtn)
|
||||||
|
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders Routes nav link', () => {
|
// 7. renders expand toggle label when collapsed
|
||||||
renderSidebar()
|
it('renders expand toggle when sidebar is collapsed', () => {
|
||||||
expect(screen.getByText('Routes')).toBeInTheDocument()
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar collapsed onCollapseToggle={vi.fn()}>
|
||||||
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders bottom links', () => {
|
// 8. renders search input and calls onSearchChange
|
||||||
renderSidebar()
|
it('renders search input and calls onSearchChange on input', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSearchChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar searchValue="" onSearchChange={onSearchChange}>
|
||||||
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Filter...')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.type(input, 'hello')
|
||||||
|
expect(onSearchChange).toHaveBeenCalled()
|
||||||
|
// Each keystroke fires once
|
||||||
|
expect(onSearchChange.mock.calls[0][0]).toBe('h')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9. hides search when collapsed
|
||||||
|
it('hides search input when sidebar is collapsed', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar collapsed searchValue="" onSearchChange={vi.fn()}>
|
||||||
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 10. hides search when onSearchChange not provided
|
||||||
|
it('hides search input when onSearchChange is not provided', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar searchValue="">
|
||||||
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 11. renders FooterLinks with icons and labels
|
||||||
|
it('renders FooterLinks with icon and label', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink
|
||||||
|
icon={<span data-testid="footer-icon">ic</span>}
|
||||||
|
label="Admin"
|
||||||
|
/>
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('footer-icon')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Admin')).toBeInTheDocument()
|
expect(screen.getByText('Admin')).toBeInTheDocument()
|
||||||
expect(screen.getByText('API Docs')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders app names in the Applications tree', () => {
|
// 12. hides FooterLink labels when collapsed and sets title tooltip
|
||||||
renderSidebar()
|
it('hides FooterLink label when collapsed and exposes title tooltip', () => {
|
||||||
// order-service appears in Applications, Routes, and Agents trees
|
render(
|
||||||
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
<Wrapper>
|
||||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
<Sidebar collapsed>
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Admin"
|
||||||
|
/>
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// The clickable element should carry a title attribute for tooltip
|
||||||
|
// (accessible name comes from icon content when label is hidden)
|
||||||
|
const item = screen.getByTitle('Admin')
|
||||||
|
expect(item).toHaveAttribute('title', 'Admin')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders exchange count badges', () => {
|
// 13. calls FooterLink onClick
|
||||||
renderSidebar()
|
it('calls FooterLink onClick when clicked', async () => {
|
||||||
expect(screen.getByText('1.4k')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders agent live count badge in Agents tree', () => {
|
|
||||||
renderSidebar()
|
|
||||||
expect(screen.getByText('2/2 live')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not show starred section when nothing is starred', () => {
|
|
||||||
renderSidebar()
|
|
||||||
expect(screen.queryByText('★ Starred')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows starred section after starring an item', async () => {
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderSidebar()
|
const onClick = vi.fn()
|
||||||
|
|
||||||
// Find the first app row (order-service in Applications tree) and hover to reveal star
|
render(
|
||||||
const appRows = screen.getAllByText('order-service')
|
<Wrapper>
|
||||||
const appRow = appRows[0].closest('[role="treeitem"]')!
|
<Sidebar>
|
||||||
await user.hover(appRow)
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
// Click the star button
|
await user.click(screen.getByText('Admin'))
|
||||||
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')!
|
expect(onClick).toHaveBeenCalledTimes(1)
|
||||||
await user.click(starBtn)
|
|
||||||
|
|
||||||
expect(screen.getByText('★ Starred')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters tree items by search', async () => {
|
// 14. renders Section as icon-rail item when sidebar collapsed
|
||||||
|
it('renders Section as icon-rail item when sidebar is collapsed', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar collapsed>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span data-testid="section-icon">ic</span>}
|
||||||
|
label="Settings"
|
||||||
|
open={false}
|
||||||
|
onToggle={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Label text should not be visible (only as tooltip via title attr)
|
||||||
|
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Rail item carries title attribute for tooltip
|
||||||
|
// (accessible name comes from icon content when label is hidden)
|
||||||
|
const railItem = screen.getByTitle('Settings')
|
||||||
|
expect(railItem).toHaveAttribute('title', 'Settings')
|
||||||
|
|
||||||
|
// Icon should still render
|
||||||
|
expect(screen.getByTestId('section-icon')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Section children should not be rendered
|
||||||
|
expect(screen.queryByText('child')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 15. fires both onCollapseToggle and onToggle when icon-rail section clicked
|
||||||
|
it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderSidebar()
|
const onCollapseToggle = vi.fn()
|
||||||
|
const onToggle = vi.fn()
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Filter...')
|
render(
|
||||||
await user.type(searchInput, 'payment')
|
<Wrapper>
|
||||||
|
<Sidebar collapsed onCollapseToggle={onCollapseToggle}>
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<span>ic</span>}
|
||||||
|
label="Settings"
|
||||||
|
open={false}
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
<div>child</div>
|
||||||
|
</Sidebar.Section>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
// payment-svc should still be visible (may appear in multiple trees)
|
const railItem = screen.getByTitle('Settings')
|
||||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
await user.click(railItem)
|
||||||
|
|
||||||
|
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('expands tree to show children when chevron is clicked', async () => {
|
// 16. applies active highlight to FooterLink
|
||||||
const user = userEvent.setup()
|
it('applies active highlight class to FooterLink when active', () => {
|
||||||
renderSidebar()
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" active />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
// Find the expand button for order-service in Applications tree
|
const item = screen.getByText('Admin').closest('[role="button"]')!
|
||||||
const expandBtns = screen.getAllByLabelText('Expand')
|
expect(item.className).toMatch(/bottomItemActive/)
|
||||||
await user.click(expandBtns[0])
|
|
||||||
|
|
||||||
// Routes should now be visible
|
|
||||||
expect(screen.getByText('order-intake')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('order-enrichment')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('collapses expanded tree when chevron is clicked again', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
renderSidebar()
|
|
||||||
|
|
||||||
const expandBtns = screen.getAllByLabelText('Expand')
|
|
||||||
await user.click(expandBtns[0])
|
|
||||||
expect(screen.getByText('order-intake')).toBeInTheDocument()
|
|
||||||
|
|
||||||
const collapseBtn = screen.getByLabelText('Collapse')
|
|
||||||
await user.click(collapseBtn)
|
|
||||||
expect(screen.queryByText('order-intake')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render apps with no agents in the Agents tree', () => {
|
|
||||||
renderSidebar()
|
|
||||||
// payment-svc has no agents, so it shouldn't appear under the Agents section header
|
|
||||||
// But it still appears under Applications. Let's check the agent tree specifically.
|
|
||||||
const agentBadges = screen.queryAllByText(/\/.*live/)
|
|
||||||
// Only order-service should have an agent badge
|
|
||||||
expect(agentBadges).toHaveLength(1)
|
|
||||||
expect(agentBadges[0].textContent).toBe('2/2 live')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,563 +1,253 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { type ReactNode, Children, isValidElement } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import {
|
||||||
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
|
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
|
||||||
onNavigate?: (path: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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: <ChevronRight size={12} />,
|
|
||||||
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: <ChevronRight size={12} />,
|
|
||||||
badge: formatCount(route.exchangeCount),
|
|
||||||
path: `/routes/${app.id}/${route.id}`,
|
|
||||||
starrable: true,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|
||||||
return apps
|
|
||||||
.filter((app) => app.agents.length > 0)
|
|
||||||
.map((app) => {
|
|
||||||
const liveCount = app.agents.filter((a) => a.status === 'live').length
|
|
||||||
return {
|
|
||||||
id: `agents:${app.id}`,
|
|
||||||
label: app.name,
|
|
||||||
icon: <StatusDot variant={app.health} />,
|
|
||||||
badge: `${liveCount}/${app.agents.length} live`,
|
|
||||||
path: `/agents/${app.id}`,
|
|
||||||
starrable: true,
|
|
||||||
starKey: `agents:${app.id}`,
|
|
||||||
children: app.agents.map((agent) => ({
|
|
||||||
id: `agent:${app.id}:${agent.id}`,
|
|
||||||
starKey: `${app.id}:${agent.id}`,
|
|
||||||
label: agent.name,
|
|
||||||
badge: `${agent.tps.toFixed(1)}/s`,
|
|
||||||
path: `/agents/${app.id}/${agent.id}`,
|
|
||||||
starrable: true,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Starred section helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StarredItem {
|
|
||||||
starKey: string
|
|
||||||
label: string
|
label: string
|
||||||
icon?: React.ReactNode
|
open: boolean
|
||||||
path: string
|
onToggle: () => void
|
||||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
active?: boolean
|
||||||
parentApp?: string
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
|
interface SidebarFooterProps {
|
||||||
const items: StarredItem[] = []
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
for (const app of apps) {
|
|
||||||
if (starredIds.has(app.id)) {
|
|
||||||
items.push({
|
|
||||||
starKey: app.id,
|
|
||||||
label: app.name,
|
|
||||||
icon: <StatusDot variant={app.health} />,
|
|
||||||
path: `/apps/${app.id}`,
|
|
||||||
type: 'application',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const route of app.routes) {
|
|
||||||
const key = `${app.id}:${route.id}`
|
|
||||||
if (starredIds.has(key)) {
|
|
||||||
items.push({
|
|
||||||
starKey: key,
|
|
||||||
label: route.name,
|
|
||||||
path: `/apps/${app.id}/${route.id}`,
|
|
||||||
type: 'route',
|
|
||||||
parentApp: app.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const agentsAppKey = `agents:${app.id}`
|
|
||||||
if (starredIds.has(agentsAppKey)) {
|
|
||||||
items.push({
|
|
||||||
starKey: agentsAppKey,
|
|
||||||
label: app.name,
|
|
||||||
icon: <StatusDot variant={app.health} />,
|
|
||||||
path: `/agents/${app.id}`,
|
|
||||||
type: 'agent',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const agent of app.agents) {
|
|
||||||
const key = `${app.id}:${agent.id}`
|
|
||||||
if (starredIds.has(key)) {
|
|
||||||
items.push({
|
|
||||||
starKey: key,
|
|
||||||
label: agent.name,
|
|
||||||
path: `/agents/${app.id}/${agent.id}`,
|
|
||||||
type: 'agent',
|
|
||||||
parentApp: app.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Routes tree starred items
|
|
||||||
const routesAppKey = `routes:${app.id}`
|
|
||||||
if (starredIds.has(routesAppKey)) {
|
|
||||||
items.push({
|
|
||||||
starKey: routesAppKey,
|
|
||||||
label: app.name,
|
|
||||||
icon: <StatusDot variant={app.health} />,
|
|
||||||
path: `/routes/${app.id}`,
|
|
||||||
type: 'routestat',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const route of app.routes) {
|
|
||||||
const routeKey = `routes:${app.id}:${route.id}`
|
|
||||||
if (starredIds.has(routeKey)) {
|
|
||||||
items.push({
|
|
||||||
starKey: routeKey,
|
|
||||||
label: route.name,
|
|
||||||
path: `/routes/${app.id}/${route.id}`,
|
|
||||||
type: 'routestat',
|
|
||||||
parentApp: app.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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`}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
function SidebarSection({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
const { collapsed, onCollapseToggle } = useSidebarContext()
|
||||||
|
|
||||||
export function Sidebar({ apps, className, onNavigate }: 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 routerNavigate = useNavigate()
|
|
||||||
const nav = onNavigate ?? routerNavigate
|
|
||||||
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={() => nav('/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 && children}
|
||||||
{/* Search */}
|
</div>
|
||||||
<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={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.searchClear}
|
|
||||||
onClick={() => setSearch('')}
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</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 ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
|
||||||
onClick={() => nav('/apps')}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
|
|
||||||
>
|
|
||||||
Applications
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!appsCollapsed && (
|
|
||||||
<SidebarTree
|
|
||||||
nodes={appNodes}
|
|
||||||
selectedPath={effectiveSelectedPath}
|
|
||||||
isStarred={isStarred}
|
|
||||||
onToggleStar={toggleStar}
|
|
||||||
filterQuery={search}
|
|
||||||
persistKey="cameleer:expanded:apps"
|
|
||||||
autoRevealPath={sidebarRevealPath}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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 ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
|
||||||
onClick={() => nav('/agents')}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
|
|
||||||
>
|
|
||||||
Agents
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!agentsCollapsed && (
|
|
||||||
<SidebarTree
|
|
||||||
nodes={agentNodes}
|
|
||||||
selectedPath={effectiveSelectedPath}
|
|
||||||
isStarred={isStarred}
|
|
||||||
onToggleStar={toggleStar}
|
|
||||||
filterQuery={search}
|
|
||||||
persistKey="cameleer:expanded:agents"
|
|
||||||
autoRevealPath={sidebarRevealPath}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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 ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
|
||||||
onClick={() => nav('/routes')}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
|
|
||||||
>
|
|
||||||
Routes
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!routesCollapsed && (
|
|
||||||
<SidebarTree
|
|
||||||
nodes={routeNodes}
|
|
||||||
selectedPath={effectiveSelectedPath}
|
|
||||||
isStarred={isStarred}
|
|
||||||
onToggleStar={toggleStar}
|
|
||||||
filterQuery={search}
|
|
||||||
persistKey="cameleer:expanded:routes"
|
|
||||||
autoRevealPath={sidebarRevealPath}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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={nav}
|
|
||||||
onRemove={toggleStar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{starredRoutes.length > 0 && (
|
|
||||||
<StarredGroup
|
|
||||||
label="Routes"
|
|
||||||
items={starredRoutes}
|
|
||||||
onNavigate={nav}
|
|
||||||
onRemove={toggleStar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{starredAgents.length > 0 && (
|
|
||||||
<StarredGroup
|
|
||||||
label="Agents"
|
|
||||||
items={starredAgents}
|
|
||||||
onNavigate={nav}
|
|
||||||
onRemove={toggleStar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{starredRouteStats.length > 0 && (
|
|
||||||
<StarredGroup
|
|
||||||
label="Routes"
|
|
||||||
items={starredRouteStats}
|
|
||||||
onNavigate={nav}
|
|
||||||
onRemove={toggleStar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom links */}
|
|
||||||
<div className={styles.bottom}>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
styles.bottomItem,
|
|
||||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
onClick={() => nav('/admin')}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/admin') }}
|
|
||||||
>
|
|
||||||
<span className={styles.bottomIcon}><Settings size={14} /></span>
|
|
||||||
<div className={styles.itemInfo}>
|
|
||||||
<div className={styles.itemName}>Admin</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
styles.bottomItem,
|
|
||||||
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
onClick={() => nav('/api-docs')}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/api-docs') }}
|
|
||||||
>
|
|
||||||
<span className={styles.bottomIcon}><FileText size={14} /></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 remaining children */}
|
||||||
|
{(() => {
|
||||||
|
const childArray = Children.toArray(children)
|
||||||
|
const headerIdx = childArray.findIndex(
|
||||||
|
(child) => isValidElement(child) && child.type === SidebarHeader,
|
||||||
|
)
|
||||||
|
const header = headerIdx >= 0 ? childArray[headerIdx] : null
|
||||||
|
const rest = headerIdx >= 0
|
||||||
|
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
|
||||||
|
: childArray
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
{onSearchChange && !collapsed && (
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<div className={styles.searchInner}>
|
||||||
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
|
<Search size={12} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className={styles.searchInput}
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter..."
|
||||||
|
value={searchValue ?? ''}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => onSearchChange('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rest}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</aside>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compound export ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const Sidebar = Object.assign(SidebarRoot, {
|
||||||
|
Header: SidebarHeader,
|
||||||
|
Section: SidebarSection,
|
||||||
|
Footer: SidebarFooter,
|
||||||
|
FooterLink: SidebarFooterLink,
|
||||||
|
})
|
||||||
|
|||||||
14
src/design-system/layout/Sidebar/SidebarContext.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
export interface SidebarContextValue {
|
||||||
|
collapsed: boolean
|
||||||
|
onCollapseToggle?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarContext = createContext<SidebarContextValue>({
|
||||||
|
collapsed: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useSidebarContext(): SidebarContextValue {
|
||||||
|
return useContext(SidebarContext)
|
||||||
|
}
|
||||||
@@ -124,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({
|
||||||
@@ -222,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
|
||||||
@@ -371,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}
|
||||||
|
|||||||
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,14 +153,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.env {
|
.env {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
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;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,32 @@
|
|||||||
import { Search, Moon, Sun, Power } from 'lucide-react'
|
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'
|
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||||
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||||
|
|
||||||
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
|
||||||
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,
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const globalFilters = useGlobalFilters()
|
|
||||||
const commandPalette = useCommandPalette()
|
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
const breadcrumbOverride = useBreadcrumbOverride()
|
const breadcrumbOverride = useBreadcrumbOverride()
|
||||||
|
|
||||||
@@ -44,54 +35,11 @@ export function TopBar({
|
|||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
<Search size={13} />
|
|
||||||
</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}
|
||||||
@@ -102,7 +50,7 @@ export function TopBar({
|
|||||||
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
{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
|
||||||
@@ -113,6 +61,8 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
|
...(userMenuItems ?? []),
|
||||||
|
...(userMenuItems?.length ? [{ label: '', divider: true }] : []),
|
||||||
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
|
{ 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'
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.titleText {
|
.titleText {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -49,5 +49,5 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dayHeader {
|
.dayHeader {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeLabel {
|
.timeLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -49,7 +49,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-inset);
|
background: var(--bg-inset);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ export function InlineEdit({ value, onSave, placeholder, disabled, className }:
|
|||||||
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
||||||
<span
|
<span
|
||||||
className={isEmpty ? styles.placeholder : styles.value}
|
className={isEmpty ? styles.placeholder : styles.value}
|
||||||
|
role="button"
|
||||||
|
tabIndex={disabled ? undefined : 0}
|
||||||
onClick={startEdit}
|
onClick={startEdit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
startEdit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isEmpty ? placeholder : value}
|
{isEmpty ? placeholder : value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.kbd {
|
.kbd {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.mono { font-family: var(--font-mono); }
|
.mono { font-family: var(--font-mono); }
|
||||||
.xs { font-size: 10px; }
|
.xs { font-size: 12px; }
|
||||||
.sm { font-size: 11px; }
|
.sm { font-size: 12px; }
|
||||||
.md { font-size: 13px; }
|
.md { font-size: 13px; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1.2px;
|
letter-spacing: 1.2px;
|
||||||
|
|||||||
@@ -35,6 +35,6 @@
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1.2px;
|
letter-spacing: 1.2px;
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend {
|
.trend {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
.trend.neutral { color: var(--text-muted); }
|
.trend.neutral { color: var(--text-muted); }
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
color: var(--bg-surface);
|
color: var(--bg-surface);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
|||||||
interface GlobalFilterContextValue {
|
interface GlobalFilterContextValue {
|
||||||
timeRange: TimeRange
|
timeRange: TimeRange
|
||||||
setTimeRange: (range: TimeRange) => void
|
setTimeRange: (range: TimeRange) => void
|
||||||
|
refreshTimeRange: () => void
|
||||||
statusFilters: Set<ExchangeStatus>
|
statusFilters: Set<ExchangeStatus>
|
||||||
toggleStatus: (status: ExchangeStatus) => void
|
toggleStatus: (status: ExchangeStatus) => void
|
||||||
clearStatusFilters: () => void
|
clearStatusFilters: () => void
|
||||||
@@ -76,6 +77,14 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [autoRefresh, timeRange.preset])
|
}, [autoRefresh, timeRange.preset])
|
||||||
|
|
||||||
|
// Recompute time range from preset on demand (for manual refresh in PAUSED mode)
|
||||||
|
const refreshTimeRange = useCallback(() => {
|
||||||
|
if (timeRange.preset) {
|
||||||
|
const { start, end } = computePresetRange(timeRange.preset)
|
||||||
|
setTimeRangeState({ start, end, preset: timeRange.preset })
|
||||||
|
}
|
||||||
|
}, [timeRange.preset])
|
||||||
|
|
||||||
const isInTimeRange = useCallback(
|
const isInTimeRange = useCallback(
|
||||||
(timestamp: Date) => {
|
(timestamp: Date) => {
|
||||||
if (timeRange.preset) {
|
if (timeRange.preset) {
|
||||||
@@ -90,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalFilterContext.Provider
|
<GlobalFilterContext.Provider
|
||||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</GlobalFilterContext.Provider>
|
</GlobalFilterContext.Provider>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
/* Typography */
|
/* Typography */
|
||||||
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
|
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
--font-size-min: 12px;
|
||||||
|
|
||||||
/* Spacing & Radii */
|
/* Spacing & Radii */
|
||||||
--radius-sm: 5px;
|
--radius-sm: 5px;
|
||||||
|
|||||||
451
src/layout/LayoutShell.tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import { useState, useEffect, useMemo, type ReactNode } from 'react'
|
||||||
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, X } from 'lucide-react'
|
||||||
|
import { AppShell } from '../design-system/layout/AppShell/AppShell'
|
||||||
|
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
|
||||||
|
import { StatusDot } from '../design-system/primitives/StatusDot/StatusDot'
|
||||||
|
import { SIDEBAR_APPS } from '../mocks/sidebar'
|
||||||
|
import type { SidebarApp } from '../mocks/sidebar'
|
||||||
|
import camelLogoUrl from '../assets/camel-logo.svg'
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatCount(n: number): string {
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tree node builders ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||||
|
return apps.map((app) => ({
|
||||||
|
id: app.id,
|
||||||
|
label: app.name,
|
||||||
|
icon: <StatusDot status={app.health} />,
|
||||||
|
badge: formatCount(app.exchangeCount),
|
||||||
|
path: `/apps/${app.id}`,
|
||||||
|
starrable: true,
|
||||||
|
starKey: `app:${app.id}`,
|
||||||
|
children: app.routes.map((route) => ({
|
||||||
|
id: `${app.id}/${route.id}`,
|
||||||
|
label: route.name,
|
||||||
|
icon: <ChevronRight size={12} />,
|
||||||
|
badge: formatCount(route.exchangeCount),
|
||||||
|
path: `/apps/${app.id}/${route.id}`,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||||
|
return apps
|
||||||
|
.filter((app) => app.routes.length > 0)
|
||||||
|
.map((app) => ({
|
||||||
|
id: `routes:${app.id}`,
|
||||||
|
label: app.name,
|
||||||
|
icon: <StatusDot status={app.health} />,
|
||||||
|
badge: `${app.routes.length} route${app.routes.length !== 1 ? 's' : ''}`,
|
||||||
|
path: `/routes/${app.id}`,
|
||||||
|
starrable: true,
|
||||||
|
starKey: `routestat:${app.id}`,
|
||||||
|
children: app.routes.map((route) => ({
|
||||||
|
id: `routes:${app.id}/${route.id}`,
|
||||||
|
label: route.name,
|
||||||
|
icon: <ChevronRight size={12} />,
|
||||||
|
badge: formatCount(route.exchangeCount),
|
||||||
|
path: `/routes/${app.id}/${route.id}`,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||||
|
return apps
|
||||||
|
.filter((app) => app.agents.length > 0)
|
||||||
|
.map((app) => {
|
||||||
|
const liveCount = app.agents.filter((a) => a.status === 'live').length
|
||||||
|
return {
|
||||||
|
id: `agents:${app.id}`,
|
||||||
|
label: app.name,
|
||||||
|
icon: <StatusDot status={app.health} />,
|
||||||
|
badge: `${liveCount}/${app.agents.length} live`,
|
||||||
|
path: `/agents/${app.id}`,
|
||||||
|
starrable: true,
|
||||||
|
starKey: `agent:${app.id}`,
|
||||||
|
children: app.agents.map((agent) => ({
|
||||||
|
id: `agents:${app.id}/${agent.id}`,
|
||||||
|
label: agent.name,
|
||||||
|
icon: <StatusDot status={agent.status} />,
|
||||||
|
badge: `${agent.tps} tps`,
|
||||||
|
path: `/agents/${app.id}/${agent.id}`,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Starred items ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StarredItem {
|
||||||
|
starKey: string
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
path: string
|
||||||
|
type: 'application' | 'route' | 'agent' | 'routestat'
|
||||||
|
parentApp?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStarredItems(
|
||||||
|
apps: SidebarApp[],
|
||||||
|
starredIds: Set<string>,
|
||||||
|
): StarredItem[] {
|
||||||
|
const items: StarredItem[] = []
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
if (starredIds.has(`app:${app.id}`)) {
|
||||||
|
items.push({
|
||||||
|
starKey: `app:${app.id}`,
|
||||||
|
label: app.name,
|
||||||
|
icon: <Box size={12} />,
|
||||||
|
path: `/apps/${app.id}`,
|
||||||
|
type: 'application',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const route of app.routes) {
|
||||||
|
if (starredIds.has(`route:${app.id}/${route.id}`)) {
|
||||||
|
items.push({
|
||||||
|
starKey: `route:${app.id}/${route.id}`,
|
||||||
|
label: route.name,
|
||||||
|
icon: <ChevronRight size={12} />,
|
||||||
|
path: `/apps/${app.id}/${route.id}`,
|
||||||
|
type: 'route',
|
||||||
|
parentApp: app.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (starredIds.has(`routestat:${app.id}`)) {
|
||||||
|
items.push({
|
||||||
|
starKey: `routestat:${app.id}`,
|
||||||
|
label: app.name,
|
||||||
|
icon: <GitBranch size={12} />,
|
||||||
|
path: `/routes/${app.id}`,
|
||||||
|
type: 'routestat',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (starredIds.has(`agent:${app.id}`)) {
|
||||||
|
items.push({
|
||||||
|
starKey: `agent:${app.id}`,
|
||||||
|
label: app.name,
|
||||||
|
icon: <Cpu size={12} />,
|
||||||
|
path: `/agents/${app.id}`,
|
||||||
|
type: 'agent',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Starred group component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StarredGroupProps {
|
||||||
|
label: string
|
||||||
|
items: StarredItem[]
|
||||||
|
onRemove: (starKey: string) => void
|
||||||
|
onNavigate: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarredGroup({ label, items, onRemove, onNavigate }: StarredGroupProps) {
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
padding: '4px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.starKey}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '4px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
onClick={() => onNavigate(item.path)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', color: 'var(--text-tertiary)' }}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.label}
|
||||||
|
{item.parentApp && (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', marginLeft: 4, fontSize: 10 }}>
|
||||||
|
{item.parentApp}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove(item.starKey)
|
||||||
|
}}
|
||||||
|
aria-label={`Remove ${item.label} from starred`}
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── localStorage-backed section collapse ────────────────────────────────────
|
||||||
|
|
||||||
|
function usePersistedCollapse(key: string, defaultValue: boolean): [boolean, () => void] {
|
||||||
|
const [value, setValue] = useState(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw !== null) return raw === 'true'
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return defaultValue
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setValue((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, String(next))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return [value, toggle]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LayoutShell ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function LayoutShell() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
|
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
|
||||||
|
// Section collapse state — persisted to localStorage
|
||||||
|
const [appsCollapsed, toggleAppsCollapsed] = usePersistedCollapse('cameleer:sidebar:apps-collapsed', false)
|
||||||
|
const [agentsCollapsed, toggleAgentsCollapsed] = usePersistedCollapse('cameleer:sidebar:agents-collapsed', false)
|
||||||
|
const [routesCollapsed, toggleRoutesCollapsed] = usePersistedCollapse('cameleer:sidebar:routes-collapsed', false)
|
||||||
|
|
||||||
|
// Tree data — static, so empty deps
|
||||||
|
const appNodes = useMemo(() => buildAppTreeNodes(SIDEBAR_APPS), [])
|
||||||
|
const agentNodes = useMemo(() => buildAgentTreeNodes(SIDEBAR_APPS), [])
|
||||||
|
const routeNodes = useMemo(() => buildRouteTreeNodes(SIDEBAR_APPS), [])
|
||||||
|
|
||||||
|
// Sidebar reveal from Cmd-K navigation
|
||||||
|
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||||
|
|
||||||
|
// Auto-uncollapse matching sections when sidebarRevealPath changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarRevealPath) return
|
||||||
|
|
||||||
|
if (sidebarRevealPath.startsWith('/apps') && appsCollapsed) {
|
||||||
|
toggleAppsCollapsed()
|
||||||
|
}
|
||||||
|
if (sidebarRevealPath.startsWith('/agents') && agentsCollapsed) {
|
||||||
|
toggleAgentsCollapsed()
|
||||||
|
}
|
||||||
|
if (sidebarRevealPath.startsWith('/routes') && routesCollapsed) {
|
||||||
|
toggleRoutesCollapsed()
|
||||||
|
}
|
||||||
|
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname
|
||||||
|
|
||||||
|
// Starred items — collected and grouped
|
||||||
|
const allStarred = useMemo(
|
||||||
|
() => collectStarredItems(SIDEBAR_APPS, starredIds),
|
||||||
|
[starredIds],
|
||||||
|
)
|
||||||
|
|
||||||
|
const starredApps = allStarred.filter((s) => s.type === 'application')
|
||||||
|
const starredRoutes = allStarred.filter((s) => s.type === 'route')
|
||||||
|
const starredAgents = allStarred.filter((s) => s.type === 'agent')
|
||||||
|
const starredRouteStats = allStarred.filter((s) => s.type === 'routestat')
|
||||||
|
const hasStarred = allStarred.length > 0
|
||||||
|
|
||||||
|
const camelLogo = (
|
||||||
|
<img
|
||||||
|
src={camelLogoUrl}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 24,
|
||||||
|
filter:
|
||||||
|
'brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
sidebar={
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onCollapseToggle={() => setSidebarCollapsed((c) => !c)}
|
||||||
|
searchValue={filterQuery}
|
||||||
|
onSearchChange={setFilterQuery}
|
||||||
|
>
|
||||||
|
<Sidebar.Header
|
||||||
|
logo={camelLogo}
|
||||||
|
title="cameleer"
|
||||||
|
version="v3.2.1"
|
||||||
|
onClick={() => navigate('/apps')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Sidebar.Section
|
||||||
|
label="Applications"
|
||||||
|
icon={<Box size={14} />}
|
||||||
|
open={!appsCollapsed}
|
||||||
|
onToggle={toggleAppsCollapsed}
|
||||||
|
active={location.pathname.startsWith('/apps')}
|
||||||
|
>
|
||||||
|
<SidebarTree
|
||||||
|
nodes={appNodes}
|
||||||
|
selectedPath={effectiveSelectedPath}
|
||||||
|
isStarred={isStarred}
|
||||||
|
onToggleStar={toggleStar}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
persistKey="cameleer:expanded:apps"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
/>
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section
|
||||||
|
label="Agents"
|
||||||
|
icon={<Cpu size={14} />}
|
||||||
|
open={!agentsCollapsed}
|
||||||
|
onToggle={toggleAgentsCollapsed}
|
||||||
|
active={location.pathname.startsWith('/agents')}
|
||||||
|
>
|
||||||
|
<SidebarTree
|
||||||
|
nodes={agentNodes}
|
||||||
|
selectedPath={effectiveSelectedPath}
|
||||||
|
isStarred={isStarred}
|
||||||
|
onToggleStar={toggleStar}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
persistKey="cameleer:expanded:agents"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
/>
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section
|
||||||
|
label="Routes"
|
||||||
|
icon={<GitBranch size={14} />}
|
||||||
|
open={!routesCollapsed}
|
||||||
|
onToggle={toggleRoutesCollapsed}
|
||||||
|
active={location.pathname.startsWith('/routes')}
|
||||||
|
>
|
||||||
|
<SidebarTree
|
||||||
|
nodes={routeNodes}
|
||||||
|
selectedPath={effectiveSelectedPath}
|
||||||
|
isStarred={isStarred}
|
||||||
|
onToggleStar={toggleStar}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
persistKey="cameleer:expanded:routes"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
/>
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
{hasStarred && (
|
||||||
|
<Sidebar.Section
|
||||||
|
label="\u2605 Starred"
|
||||||
|
icon={<span />}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
active={false}
|
||||||
|
>
|
||||||
|
<StarredGroup
|
||||||
|
label="Applications"
|
||||||
|
items={starredApps}
|
||||||
|
onRemove={toggleStar}
|
||||||
|
onNavigate={navigate}
|
||||||
|
/>
|
||||||
|
<StarredGroup
|
||||||
|
label="Routes"
|
||||||
|
items={starredRoutes}
|
||||||
|
onRemove={toggleStar}
|
||||||
|
onNavigate={navigate}
|
||||||
|
/>
|
||||||
|
<StarredGroup
|
||||||
|
label="Route Stats"
|
||||||
|
items={starredRouteStats}
|
||||||
|
onRemove={toggleStar}
|
||||||
|
onNavigate={navigate}
|
||||||
|
/>
|
||||||
|
<StarredGroup
|
||||||
|
label="Agents"
|
||||||
|
items={starredAgents}
|
||||||
|
onRemove={toggleStar}
|
||||||
|
onNavigate={navigate}
|
||||||
|
/>
|
||||||
|
</Sidebar.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink
|
||||||
|
icon={<Settings size={14} />}
|
||||||
|
label="Admin"
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
active={location.pathname.startsWith('/admin')}
|
||||||
|
/>
|
||||||
|
<Sidebar.FooterLink
|
||||||
|
icon={<FileText size={14} />}
|
||||||
|
label="API Docs"
|
||||||
|
onClick={() => navigate('/api-docs')}
|
||||||
|
active={location.pathname === '/api-docs'}
|
||||||
|
/>
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,34 +3,7 @@ import { exchanges, type Exchange } from './exchanges'
|
|||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
import { agents } from './agents'
|
import { agents } from './agents'
|
||||||
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
|
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
|
||||||
|
import { formatDuration, statusLabel, statusToVariant, formatTimestamp } from '../utils/format-utils'
|
||||||
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 statusLabel(status: Exchange['status']): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'OK'
|
|
||||||
case 'failed': return 'ERR'
|
|
||||||
case 'running': return 'RUN'
|
|
||||||
case 'warning': return 'WARN'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusToVariant(status: Exchange['status']): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'success'
|
|
||||||
case 'failed': return 'error'
|
|
||||||
case 'running': return 'running'
|
|
||||||
case 'warning': return 'warning'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
|
||||||
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function healthToColor(health: SidebarApp['health']): string {
|
function healthToColor(health: SidebarApp['health']): string {
|
||||||
switch (health) {
|
switch (health) {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
import styles from './Admin.module.css'
|
import styles from './Admin.module.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -23,7 +20,7 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Admin', href: '/admin' },
|
{ label: 'Admin', href: '/admin' },
|
||||||
@@ -40,6 +37,6 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
|
|||||||
<div className={styles.adminContent}>
|
<div className={styles.adminContent}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableMeta {
|
.tableMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detailLabel {
|
.detailLabel {
|
||||||
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;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entityMeta {
|
.entityMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inheritedNote {
|
.inheritedNote {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
.breakdown {
|
.breakdown {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
.scopeSep {
|
.scopeSep {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scopeCurrent {
|
.scopeCurrent {
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionMeta {
|
.sectionMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--error-bg);
|
background: var(--error-bg);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartTitle {
|
.chartTitle {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { ChevronRight } from 'lucide-react'
|
|||||||
import styles from './AgentHealth.module.css'
|
import styles from './AgentHealth.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -28,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
import { agentEvents } from '../../mocks/agentEvents'
|
import { agentEvents } from '../../mocks/agentEvents'
|
||||||
|
|
||||||
// ── URL scope parsing ────────────────────────────────────────────────────────
|
// ── URL scope parsing ────────────────────────────────────────────────────────
|
||||||
@@ -317,19 +314,7 @@ export function AgentHealth() {
|
|||||||
const isFullWidth = scope.level !== 'all'
|
const isFullWidth = scope.level !== 'all'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
|
|
||||||
detail={
|
|
||||||
selectedInstance ? (
|
|
||||||
<DetailPanel
|
|
||||||
open={panelOpen}
|
|
||||||
onClose={() => setPanelOpen(false)}
|
|
||||||
title={selectedInstance.name}
|
|
||||||
tabs={detailTabs}
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={buildBreadcrumb(scope)}
|
breadcrumb={buildBreadcrumb(scope)}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
@@ -454,6 +439,16 @@ export function AgentHealth() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
|
||||||
|
{/* Detail panel (portals itself) */}
|
||||||
|
{selectedInstance && (
|
||||||
|
<DetailPanel
|
||||||
|
open={panelOpen}
|
||||||
|
onClose={() => setPanelOpen(false)}
|
||||||
|
title={selectedInstance.name}
|
||||||
|
tabs={detailTabs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
.scopeSep {
|
.scopeSep {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scopeCurrent {
|
.scopeCurrent {
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartMeta {
|
.chartMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { ChevronRight } from 'lucide-react'
|
|||||||
import styles from './AgentInstance.module.css'
|
import styles from './AgentInstance.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -28,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
import { agents } from '../../mocks/agents'
|
import { agents } from '../../mocks/agents'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
import { agentEvents } from '../../mocks/agentEvents'
|
import { agentEvents } from '../../mocks/agentEvents'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -127,12 +124,12 @@ export function AgentInstance() {
|
|||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} />
|
<TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} />
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.notFound}>Agent instance not found.</div>
|
<div className={styles.notFound}>Agent instance not found.</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +150,7 @@ export function AgentInstance() {
|
|||||||
const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
|
const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
@@ -302,6 +299,6 @@ export function AgentInstance() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
|
|
||||||
export function ApiDocs() {
|
export function ApiDocs() {
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[{ label: 'API Documentation' }]}
|
breadcrumb={[{ label: 'API Documentation' }]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
@@ -17,6 +14,6 @@ export function ApiDocs() {
|
|||||||
title="API Documentation"
|
title="API Documentation"
|
||||||
description="API documentation coming soon."
|
description="API documentation coming soon."
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
|
|
||||||
export function AppDetail() {
|
export function AppDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
@@ -23,6 +20,6 @@ export function AppDetail() {
|
|||||||
title="Application Detail"
|
title="Application Detail"
|
||||||
description="Application detail view coming soon."
|
description="Application detail view coming soon."
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableMeta {
|
.tableMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,14 +128,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inlineErrorText {
|
.inlineErrorText {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineErrorHint {
|
.inlineErrorHint {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panelSectionTitle {
|
.panelSectionTitle {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
.panelSectionMeta {
|
.panelSectionMeta {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.overviewLabel {
|
.overviewLabel {
|
||||||
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;
|
||||||
@@ -209,14 +209,14 @@
|
|||||||
|
|
||||||
.errorClass {
|
.errorClass {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorMessage {
|
.errorMessage {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } fro
|
|||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -27,6 +25,9 @@ import { Badge } from '../../design-system/primitives/Badge/Badge'
|
|||||||
// Global filters
|
// Global filters
|
||||||
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { formatDuration, statusToVariant, statusLabel, toRouteNodeType, durationClass } from '../../utils/format-utils'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
|
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
|
||||||
@@ -68,12 +69,6 @@ const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
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 formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
const y = date.getFullYear()
|
const y = date.getFullYear()
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
@@ -84,24 +79,6 @@ function formatTimestamp(date: Date): string {
|
|||||||
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'success'
|
|
||||||
case 'failed': return 'error'
|
|
||||||
case 'running': return 'running'
|
|
||||||
case 'warning': return 'warning'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: Exchange['status']): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'OK'
|
|
||||||
case 'failed': return 'ERR'
|
|
||||||
case 'running': return 'RUN'
|
|
||||||
case 'warning': return 'WARN'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Table columns (base, without navigate action) ──────────────────────────
|
// ─── Table columns (base, without navigate action) ──────────────────────────
|
||||||
const BASE_COLUMNS: Column<Exchange>[] = [
|
const BASE_COLUMNS: Column<Exchange>[] = [
|
||||||
{
|
{
|
||||||
@@ -152,7 +129,7 @@ const BASE_COLUMNS: Column<Exchange>[] = [
|
|||||||
header: 'Duration',
|
header: 'Duration',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
|
<MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
|
||||||
{formatDuration(row.durationMs)}
|
{formatDuration(row.durationMs)}
|
||||||
</MonoText>
|
</MonoText>
|
||||||
),
|
),
|
||||||
@@ -169,14 +146,6 @@ const BASE_COLUMNS: Column<Exchange>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function durationClass(ms: number, status: Exchange['status']): string {
|
|
||||||
if (status === 'failed') return styles.durBreach
|
|
||||||
if (ms < 100) return styles.durFast
|
|
||||||
if (ms < 200) return styles.durNormal
|
|
||||||
if (ms < 300) return styles.durSlow
|
|
||||||
return styles.durBreach
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHORTCUTS = [
|
const SHORTCUTS = [
|
||||||
{ keys: 'Ctrl+K', label: 'Search' },
|
{ keys: 'Ctrl+K', label: 'Search' },
|
||||||
{ keys: '↑↓', label: 'Navigate rows' },
|
{ keys: '↑↓', label: 'Navigate rows' },
|
||||||
@@ -259,16 +228,6 @@ export function Dashboard() {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map processor types to RouteNode types
|
|
||||||
function toRouteNodeType(procType: string): RouteNode['type'] {
|
|
||||||
switch (procType) {
|
|
||||||
case 'consumer': return 'from'
|
|
||||||
case 'transform': return 'process'
|
|
||||||
case 'enrich': return 'process'
|
|
||||||
default: return procType as RouteNode['type']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build RouteFlow nodes from exchange processors
|
// Build RouteFlow nodes from exchange processors
|
||||||
const routeNodes: RouteNode[] = selectedExchange
|
const routeNodes: RouteNode[] = selectedExchange
|
||||||
? selectedExchange.processors.map((p) => ({
|
? selectedExchange.processors.map((p) => ({
|
||||||
@@ -287,106 +246,7 @@ export function Dashboard() {
|
|||||||
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
|
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={
|
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
|
||||||
}
|
|
||||||
detail={
|
|
||||||
selectedExchange ? (
|
|
||||||
<DetailPanel
|
|
||||||
open={panelOpen}
|
|
||||||
onClose={() => setPanelOpen(false)}
|
|
||||||
title={`${selectedExchange.orderId} — ${selectedExchange.route}`}
|
|
||||||
>
|
|
||||||
{/* Link to full detail page */}
|
|
||||||
<div className={styles.panelSection}>
|
|
||||||
<button
|
|
||||||
className={styles.openDetailLink}
|
|
||||||
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
|
||||||
>
|
|
||||||
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overview */}
|
|
||||||
<div className={styles.panelSection}>
|
|
||||||
<div className={styles.panelSectionTitle}>Overview</div>
|
|
||||||
<div className={styles.overviewGrid}>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Status</span>
|
|
||||||
<span className={styles.statusCell}>
|
|
||||||
<StatusDot variant={statusToVariant(selectedExchange.status)} />
|
|
||||||
<span>{statusLabel(selectedExchange.status)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Duration</span>
|
|
||||||
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Route</span>
|
|
||||||
<span>{selectedExchange.route}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Customer</span>
|
|
||||||
<MonoText size="sm">{selectedExchange.customer}</MonoText>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Agent</span>
|
|
||||||
<MonoText size="sm">{selectedExchange.agent}</MonoText>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Correlation</span>
|
|
||||||
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Timestamp</span>
|
|
||||||
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Errors */}
|
|
||||||
{totalErrors > 0 && (
|
|
||||||
<div className={styles.panelSection}>
|
|
||||||
<div className={styles.panelSectionTitle}>
|
|
||||||
Errors
|
|
||||||
{totalErrors > 1 && (
|
|
||||||
<Badge label={`+${totalErrors - 1} more`} color="error" variant="outlined" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.errorBlock}>
|
|
||||||
<div className={styles.errorClass}>
|
|
||||||
{selectedExchange.errorClass ?? processorErrors[0]?.name}
|
|
||||||
</div>
|
|
||||||
<div className={styles.errorMessage}>
|
|
||||||
{selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Route Flow */}
|
|
||||||
<div className={styles.panelSection}>
|
|
||||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
|
||||||
<RouteFlow nodes={routeNodes} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Processor Timeline */}
|
|
||||||
<div className={styles.panelSection}>
|
|
||||||
<div className={styles.panelSectionTitle}>
|
|
||||||
Processor Timeline
|
|
||||||
<span className={styles.panelSectionMeta}>{formatDuration(selectedExchange.durationMs)}</span>
|
|
||||||
</div>
|
|
||||||
<ProcessorTimeline
|
|
||||||
processors={selectedExchange.processors}
|
|
||||||
totalMs={selectedExchange.durationMs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DetailPanel>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={
|
breadcrumb={
|
||||||
@@ -444,6 +304,101 @@ export function Dashboard() {
|
|||||||
|
|
||||||
{/* Shortcuts bar */}
|
{/* Shortcuts bar */}
|
||||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||||
</AppShell>
|
|
||||||
|
{/* Detail panel (portals itself) */}
|
||||||
|
{selectedExchange && (
|
||||||
|
<DetailPanel
|
||||||
|
open={panelOpen}
|
||||||
|
onClose={() => setPanelOpen(false)}
|
||||||
|
title={`${selectedExchange.orderId} — ${selectedExchange.route}`}
|
||||||
|
>
|
||||||
|
{/* Link to full detail page */}
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<button
|
||||||
|
className={styles.openDetailLink}
|
||||||
|
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
||||||
|
>
|
||||||
|
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<div className={styles.panelSectionTitle}>Overview</div>
|
||||||
|
<div className={styles.overviewGrid}>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Status</span>
|
||||||
|
<span className={styles.statusCell}>
|
||||||
|
<StatusDot variant={statusToVariant(selectedExchange.status)} />
|
||||||
|
<span>{statusLabel(selectedExchange.status)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Duration</span>
|
||||||
|
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Route</span>
|
||||||
|
<span>{selectedExchange.route}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Customer</span>
|
||||||
|
<MonoText size="sm">{selectedExchange.customer}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Agent</span>
|
||||||
|
<MonoText size="sm">{selectedExchange.agent}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Correlation</span>
|
||||||
|
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Timestamp</span>
|
||||||
|
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errors */}
|
||||||
|
{totalErrors > 0 && (
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<div className={styles.panelSectionTitle}>
|
||||||
|
Errors
|
||||||
|
{totalErrors > 1 && (
|
||||||
|
<Badge label={`+${totalErrors - 1} more`} color="error" variant="outlined" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.errorBlock}>
|
||||||
|
<div className={styles.errorClass}>
|
||||||
|
{selectedExchange.errorClass ?? processorErrors[0]?.name}
|
||||||
|
</div>
|
||||||
|
<div className={styles.errorMessage}>
|
||||||
|
{selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Route Flow */}
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||||
|
<RouteFlow nodes={routeNodes} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processor Timeline */}
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<div className={styles.panelSectionTitle}>
|
||||||
|
Processor Timeline
|
||||||
|
<span className={styles.panelSectionMeta}>{formatDuration(selectedExchange.durationMs)}</span>
|
||||||
|
</div>
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={selectedExchange.processors}
|
||||||
|
totalMs={selectedExchange.durationMs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DetailPanel>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.headerStatLabel {
|
.headerStatLabel {
|
||||||
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;
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chainLabel {
|
.chainLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
|
|
||||||
.procCount {
|
.procCount {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
|
|
||||||
.toggleBtn {
|
.toggleBtn {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
|
|
||||||
.panelTag {
|
.panelTag {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -326,7 +326,7 @@
|
|||||||
grid-template-columns: 140px 1fr;
|
grid-template-columns: 140px 1fr;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerKvRow:last-child {
|
.headerKvRow:last-child {
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionLabel {
|
.sectionLabel {
|
||||||
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;
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -389,7 +389,7 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--error-bg);
|
background: var(--error-bg);
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
|
|
||||||
.errorMessageBox {
|
.errorMessageBox {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--error-bg);
|
background: var(--error-bg);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 120px 1fr;
|
grid-template-columns: 120px 1fr;
|
||||||
gap: 4px 12px;
|
gap: 4px 12px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorDetailLabel {
|
.errorDetailLabel {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom'
|
|||||||
import styles from './ExchangeDetail.module.css'
|
import styles from './ExchangeDetail.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
@@ -20,28 +18,16 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
|||||||
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
||||||
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { formatDuration, statusToVariant, toRouteNodeType } from '../../utils/format-utils'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges } from '../../mocks/exchanges'
|
import { exchanges } from '../../mocks/exchanges'
|
||||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||||
|
|
||||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
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 statusToVariant(status: 'completed' | 'failed' | 'running' | 'warning'): 'success' | 'error' | 'running' | 'warning' {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'success'
|
|
||||||
case 'failed': return 'error'
|
|
||||||
case 'running': return 'running'
|
|
||||||
case 'warning': return 'warning'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string {
|
function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed': return 'COMPLETED'
|
case 'completed': return 'COMPLETED'
|
||||||
@@ -147,16 +133,6 @@ function generateExchangeSnapshotOut(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map processor types to RouteNode types
|
|
||||||
function toRouteNodeType(procType: string): RouteNode['type'] {
|
|
||||||
switch (procType) {
|
|
||||||
case 'consumer': return 'from'
|
|
||||||
case 'transform': return 'process'
|
|
||||||
case 'enrich': return 'process'
|
|
||||||
default: return procType as RouteNode['type']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ExchangeDetail component ─────────────────────────────────────────────────
|
// ─── ExchangeDetail component ─────────────────────────────────────────────────
|
||||||
export function ExchangeDetail() {
|
export function ExchangeDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -196,11 +172,7 @@ export function ExchangeDetail() {
|
|||||||
// Not found state
|
// Not found state
|
||||||
if (!exchange) {
|
if (!exchange) {
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={
|
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
@@ -213,7 +185,7 @@ export function ExchangeDetail() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
|
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,11 +201,7 @@ export function ExchangeDetail() {
|
|||||||
const isSelectedFailed = selectedProc?.status === 'fail'
|
const isSelectedFailed = selectedProc?.status === 'fail'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
sidebar={
|
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
@@ -454,6 +422,6 @@ export function ExchangeDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navLabel {
|
.navLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import styles from './Inventory.module.css'
|
|||||||
import { PrimitivesSection } from './sections/PrimitivesSection'
|
import { PrimitivesSection } from './sections/PrimitivesSection'
|
||||||
import { CompositesSection } from './sections/CompositesSection'
|
import { CompositesSection } from './sections/CompositesSection'
|
||||||
import { LayoutSection } from './sections/LayoutSection'
|
import { LayoutSection } from './sections/LayoutSection'
|
||||||
|
import { BrandAssetsSection } from './sections/BrandAssetsSection'
|
||||||
|
|
||||||
const NAV_SECTIONS = [
|
const NAV_SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -93,6 +94,13 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'TopBar', href: '#topbar' },
|
{ label: 'TopBar', href: '#topbar' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Brand Assets',
|
||||||
|
href: '#brand-assets',
|
||||||
|
components: [
|
||||||
|
{ label: 'Cameleer3 Logo', href: '#brand-assets' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Inventory() {
|
export function Inventory() {
|
||||||
@@ -121,6 +129,7 @@ export function Inventory() {
|
|||||||
<PrimitivesSection />
|
<PrimitivesSection />
|
||||||
<CompositesSection />
|
<CompositesSection />
|
||||||
<LayoutSection />
|
<LayoutSection />
|
||||||
|
<BrandAssetsSection />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
86
src/pages/Inventory/sections/BrandAssetsSection.module.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
.section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.componentCard {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.componentTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.componentDesc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demoArea {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoPreview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-body);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoPreview img {
|
||||||
|
display: block;
|
||||||
|
image-rendering: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoExport {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-body);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
77
src/pages/Inventory/sections/BrandAssetsSection.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import styles from './BrandAssetsSection.module.css'
|
||||||
|
import camelLogoSvg from '../../../assets/camel-logo.svg'
|
||||||
|
import cameleer3Logo from '../../../assets/cameleer3-logo.png'
|
||||||
|
import cameleer3LogoSvg from '../../../assets/cameleer3-logo.svg'
|
||||||
|
|
||||||
|
const LOGO_SIZES = [16, 32, 48, 180, 192, 512] as const
|
||||||
|
|
||||||
|
export function BrandAssetsSection() {
|
||||||
|
return (
|
||||||
|
<section id="brand-assets" className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Brand Assets</h2>
|
||||||
|
|
||||||
|
<div className={styles.componentCard}>
|
||||||
|
<h3 className={styles.componentTitle}>Cameleer3 Logo (PNG)</h3>
|
||||||
|
<p className={styles.componentDesc}>
|
||||||
|
Full-resolution logo and pre-generated size variants for favicons, PWA icons, and social images.
|
||||||
|
Shipped as static assets via <code>@cameleer/design-system/assets/*</code> export.
|
||||||
|
</p>
|
||||||
|
<div className={styles.demoArea}>
|
||||||
|
<div className={styles.logoGrid}>
|
||||||
|
{LOGO_SIZES.map((size) => (
|
||||||
|
<div key={size} className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img
|
||||||
|
src={cameleer3Logo}
|
||||||
|
alt={`Logo ${size}×${size}`}
|
||||||
|
width={Math.min(size, 96)}
|
||||||
|
height={Math.min(size, 96)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>{size}×{size}</span>
|
||||||
|
<code className={styles.logoExport}>assets/cameleer3-{size}.png</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img
|
||||||
|
src={cameleer3Logo}
|
||||||
|
alt="Full resolution logo"
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>Original</span>
|
||||||
|
<code className={styles.logoExport}>assets/cameleer3-logo.png</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.componentCard}>
|
||||||
|
<h3 className={styles.componentTitle}>SVG Logos</h3>
|
||||||
|
<p className={styles.componentDesc}>
|
||||||
|
Vector logos for scalable usage. Transparent backgrounds, infinitely scalable.
|
||||||
|
</p>
|
||||||
|
<div className={styles.demoArea}>
|
||||||
|
<div className={styles.logoGrid}>
|
||||||
|
<div className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img src={cameleer3LogoSvg} alt="Cameleer3 SVG logo" width={96} height={96} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>Cameleer3 SVG</span>
|
||||||
|
<code className={styles.logoExport}>assets/cameleer3-logo.svg</code>
|
||||||
|
</div>
|
||||||
|
<div className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img src={camelLogoSvg} alt="Camel SVG logo" width={96} height={96} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>Camel SVG</span>
|
||||||
|
<code className={styles.logoExport}>assets/camel-logo.svg</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.demoLabel {
|
.demoLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.demoLabel {
|
.demoLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import styles from './LayoutSection.module.css'
|
import styles from './LayoutSection.module.css'
|
||||||
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
|
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
|
||||||
import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar'
|
import { SidebarTree } from '../../../design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
|
||||||
|
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
|
||||||
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
|
||||||
|
import { SearchTrigger } from '../../../design-system/layout/TopBar/SearchTrigger'
|
||||||
|
import { AutoRefreshToggle } from '../../../design-system/layout/TopBar/AutoRefreshToggle'
|
||||||
|
|
||||||
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,42 +27,42 @@ function DemoCard({ id, title, description, children }: DemoCardProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sample data (hierarchical) ───────────────────────────────────────────────
|
// ── Sample tree nodes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SAMPLE_APPS: SidebarApp[] = [
|
const SAMPLE_APP_NODES: SidebarTreeNode[] = [
|
||||||
{
|
{
|
||||||
id: 'app1',
|
id: 'app1',
|
||||||
name: 'cameleer-prod',
|
label: 'cameleer-prod',
|
||||||
health: 'live' as const,
|
icon: <StatusDot variant="live" />,
|
||||||
exchangeCount: 14320,
|
badge: '14.3k',
|
||||||
routes: [
|
path: '/apps/app1',
|
||||||
{ id: 'r1', name: 'order-ingest', exchangeCount: 5421 },
|
starrable: true,
|
||||||
{ id: 'r2', name: 'payment-validate', exchangeCount: 3102 },
|
starKey: 'app:app1',
|
||||||
],
|
children: [
|
||||||
agents: [
|
{ id: 'app1/r1', label: 'order-ingest', icon: <ChevronRight size={12} />, badge: '5,421', path: '/apps/app1/r1' },
|
||||||
{ id: 'ag1', name: 'agent-prod-1', status: 'live' as const, tps: 42 },
|
{ id: 'app1/r2', label: 'payment-validate', icon: <ChevronRight size={12} />, badge: '3,102', path: '/apps/app1/r2' },
|
||||||
{ id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: 38 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'app2',
|
id: 'app2',
|
||||||
name: 'cameleer-staging',
|
label: 'cameleer-staging',
|
||||||
health: 'stale' as const,
|
icon: <StatusDot variant="stale" />,
|
||||||
exchangeCount: 871,
|
badge: '871',
|
||||||
routes: [
|
path: '/apps/app2',
|
||||||
{ id: 'r3', name: 'notify-customer', exchangeCount: 2201 },
|
starrable: true,
|
||||||
],
|
starKey: 'app:app2',
|
||||||
agents: [
|
children: [
|
||||||
{ id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: 5 },
|
{ id: 'app2/r3', label: 'notify-customer', icon: <ChevronRight size={12} />, badge: '2,201', path: '/apps/app2/r3' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'app3',
|
id: 'app3',
|
||||||
name: 'cameleer-dev',
|
label: 'cameleer-dev',
|
||||||
health: 'dead' as const,
|
icon: <StatusDot variant="dead" />,
|
||||||
exchangeCount: 42,
|
badge: '42',
|
||||||
routes: [],
|
path: '/apps/app3',
|
||||||
agents: [],
|
starrable: true,
|
||||||
|
starKey: 'app:app3',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -99,10 +104,19 @@ export function LayoutSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
title="Sidebar"
|
title="Sidebar"
|
||||||
description="Navigation sidebar with hierarchical app/route/agent trees, starring, search filter, and bottom links."
|
description="Composable navigation sidebar with sections, tree navigation, and icon-rail collapse mode."
|
||||||
>
|
>
|
||||||
<div className={styles.sidebarPreview}>
|
<div className={styles.sidebarPreview}>
|
||||||
<Sidebar apps={SAMPLE_APPS} />
|
<Sidebar>
|
||||||
|
<Sidebar.Header logo={<span style={{ fontSize: 20 }}>🐪</span>} title="cameleer" version="v3.2.1" />
|
||||||
|
<Sidebar.Section label="Applications" icon={<Box size={14} />} open={true} onToggle={() => {}} active={false}>
|
||||||
|
<SidebarTree nodes={SAMPLE_APP_NODES} isStarred={() => false} onToggleStar={() => {}} />
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={<Settings size={14} />} label="Admin" />
|
||||||
|
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
@@ -110,7 +124,7 @@ export function LayoutSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="topbar"
|
id="topbar"
|
||||||
title="TopBar"
|
title="TopBar"
|
||||||
description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar."
|
description="Composable top navigation bar. Consumers pass children for the center slot (search, filters, etc.). Shell provides breadcrumb, theme toggle, env badge, and user menu."
|
||||||
>
|
>
|
||||||
<div className={styles.topbarPreview}>
|
<div className={styles.topbarPreview}>
|
||||||
<TopBar
|
<TopBar
|
||||||
@@ -120,9 +134,11 @@ export function LayoutSection() {
|
|||||||
{ label: 'order-ingest' },
|
{ label: 'order-ingest' },
|
||||||
]}
|
]}
|
||||||
environment="production"
|
environment="production"
|
||||||
|
|
||||||
user={{ name: 'Hendrik' }}
|
user={{ name: 'Hendrik' }}
|
||||||
/>
|
>
|
||||||
|
<SearchTrigger onClick={() => {}} />
|
||||||
|
<AutoRefreshToggle active={true} onChange={() => {}} />
|
||||||
|
</TopBar>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.demoLabel {
|
.demoLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||