Compare commits

...

75 Commits

Author SHA1 Message Date
hsiegeln
fd08d7a552 feat: add FileInput primitive — drag-and-drop file picker
All checks were successful
Build & Publish / publish (push) Successful in 1m24s
Dashed-border dropzone with icon, filename display, clear button,
and imperative handle (file/clear). Replaces native file inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:00:21 +02:00
hsiegeln
6ea2a29a7c fix: remove double-border on TopBar environment slot
All checks were successful
Build & Publish / publish (push) Successful in 1m38s
SonarQube Analysis / sonarqube (push) Successful in 2m24s
The .env wrapper had its own border/background/padding which caused a
nested border appearance when wrapping a Select component. Simplified
to a plain flex container — the child component handles its own styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:59:24 +02:00
hsiegeln
51d5d9337a fix: add @keyframes spin to Spinner CSS module
All checks were successful
Build & Publish / publish (push) Successful in 1m35s
CSS Modules scopes animation names, so the global @keyframes spin in
reset.css was not visible. The spinner rendered but never animated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:36:34 +02:00
hsiegeln
cd00a2e0fa chore: bump version to 0.1.7
All checks were successful
Build & Publish / publish (push) Successful in 1m42s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:03:01 +02:00
hsiegeln
b443fc787e refactor: decompose TopBar into composable shell with children slot
TopBar was a monolith that hardcoded server-specific controls (status
filters, time range, auto-refresh, search trigger). Extract these into
standalone SearchTrigger and AutoRefreshToggle components. TopBar now
accepts children for the center slot, letting consumers compose their
own controls. Fixes cameleer/cameleer-saas#53.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:00:57 +02:00
hsiegeln
4a6e6dea96 chore: add GitNexus config to .gitignore and CLAUDE.md
All checks were successful
Build & Publish / publish (push) Successful in 1m10s
SonarQube Analysis / sonarqube (push) Successful in 2m13s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:31:04 +02:00
hsiegeln
638b868649 refactor: extract duplicated utility functions into shared format-utils
All checks were successful
Build & Publish / publish (push) Successful in 1m12s
Consolidate formatDuration, statusToVariant, statusLabel, formatTimestamp,
toRouteNodeType, and durationClass from 5 page/component files into one
shared utils module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:28:32 +02:00
hsiegeln
433d0926e6 style: enforce 12px minimum font size across all components
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
SonarQube Analysis / sonarqube (push) Successful in 2m25s
Add --font-size-min: 12px token to tokens.css. Replace all 10px and 11px
font-size declarations with 12px across 54 CSS module files for improved
readability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:13:17 +02:00
hsiegeln
7e545140a2 feat: add userMenuItems prop to TopBar and export DropdownItem type
All checks were successful
Build & Publish / publish (push) Successful in 1m0s
Allows consumers to inject custom menu items above the Logout action
in the user dropdown. Items are separated from Logout by a divider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:07:26 +02:00
hsiegeln
e572df8558 fix(assets): remove semi-white background artifacts from logo SVG and PNGs
All checks were successful
Build & Publish / publish (push) Successful in 1m4s
SonarQube Analysis / sonarqube (push) Successful in 2m27s
Stripped 8 near-white path layers (brightness > 200) from the traced SVG,
leaving only amber/gold tones. Regenerated all PNG variants from the
cleaned SVG. Validated on dark and red backgrounds with zero artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:19:39 +02:00
hsiegeln
a3afe3cb1b feat(assets): add high-detail cameleer3-logo.svg traced from PNG via Inkscape
All checks were successful
Build & Publish / publish (push) Successful in 1m4s
32-scan potrace vector trace with transparent background. Added to brand
assets inventory, documentation, and package exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:58:44 +02:00
hsiegeln
4841a7ad7c refactor(assets): replace named exports with wildcard ./assets/* export
All checks were successful
Build & Publish / publish (push) Successful in 1m10s
Consumers now import via @cameleer/design-system/assets/<filename> instead
of named aliases like /logo-32. Simpler, more flexible, and supports any
future assets without adding new export entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:43:01 +02:00
hsiegeln
32a49690fa style(assets): make all logo PNGs transparent using Inkscape trace
All checks were successful
Build & Publish / publish (push) Successful in 1m7s
Traced the original logo with Inkscape (16-scan potrace, background removal)
and re-exported all PNG variants with transparent backgrounds. Also reduces
total asset size from ~1.7MB to ~630KB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:14:50 +02:00
hsiegeln
20f7b2f5aa feat(assets): ship Cameleer3 logo and favicon variants with the design system
All checks were successful
Build & Publish / publish (push) Successful in 1m5s
Add brand assets as static package exports (logo, logo-16 through logo-512, logo-svg)
with pre-generated PNG sizes for favicons, PWA icons, Apple touch icons, and social images.
Includes inventory showcase section and updated documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:17:36 +02:00
hsiegeln
5cb51e65be style(topbar): make .env badge match neutral button style of liveToggle/themeToggle
All checks were successful
Build & Publish / publish (push) Successful in 1m42s
SonarQube Analysis / sonarqube (push) Successful in 2m26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:59:00 +02:00
hsiegeln
4dcd4aaa27 feat(topbar): change environment prop from string to ReactNode
All checks were successful
Build & Publish / publish (push) Successful in 1m21s
Allows consuming apps to pass a custom dropdown or any interactive
element instead of a static string label. Rendering changed from
<span> to <div> to support block-level children.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:25:20 +02:00
hsiegeln
58320b9762 fix(topbar): rename refresh toggle labels from LIVE/PAUSED to AUTO/MANUAL
All checks were successful
Build & Publish / publish (push) Successful in 54s
SonarQube Analysis / sonarqube (push) Successful in 3m3s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:30:17 +02:00
hsiegeln
c48dffaef2 feat(global-filter): add refreshTimeRange() for manual refresh in paused mode
All checks were successful
Build & Publish / publish (push) Successful in 55s
Revert auto-sliding when paused — time range only advances with
auto-refresh on. Add refreshTimeRange() to useGlobalFilters for
on-demand recomputation from the active preset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:01:47 +02:00
hsiegeln
3ef4c5686e fix(global-filter): keep time range sliding when auto-refresh is paused
All checks were successful
Build & Publish / publish (push) Successful in 1m6s
The preset time window now advances on a 10s interval regardless of
auto-refresh state. Pausing only stops query polling — the window
stays current so manual refreshes see up-to-date data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:16:11 +02:00
hsiegeln
78e28789a5 docs: update CommandPalette entry for open SearchCategory type
All checks were successful
Build & Publish / publish (push) Successful in 1m36s
SonarQube Analysis / sonarqube (push) Successful in 2m52s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:23:32 +02:00
hsiegeln
03ec34bb5c feat(command-palette): open SearchCategory to arbitrary strings
All checks were successful
Build & Publish / publish (push) Successful in 1m33s
Widen SearchCategory from a closed union to string. Known categories
(application, exchange, attribute, route, agent) keep their labels.
Unknown categories render with title-cased labels and appear as
dynamic tabs derived from the data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:21:28 +02:00
hsiegeln
2f1df869db docs: update spec and guide for search position and chevron removal
All checks were successful
Build & Publish / publish (push) Successful in 1m7s
- COMPONENT_GUIDE: note search renders between Header and Sections,
  no chevrons on section headers
- Spec: update rendering diagrams and description to match
  implemented behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:43:00 +02:00
hsiegeln
0cf696cded fix(sidebar): move search below Header, remove section chevrons
All checks were successful
Build & Publish / publish (push) Successful in 1m3s
- Search input now renders between Sidebar.Header and first Section
  instead of above Header (fixes cameleer3-server#120)
- Remove ChevronRight/ChevronDown from section headers — the entire
  row is already clickable, chevrons added visual noise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:40:18 +02:00
hsiegeln
50a1296a9d fix(sidebar): make entire section header row clickable
All checks were successful
Build & Publish / publish (push) Successful in 2m1s
The toggle was only on the chevron button. Now the full row
(chevron + icon + label) triggers onToggle on click or Enter/Space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:59:47 +02:00
hsiegeln
9b8739b5d8 fix(a11y): add keyboard listeners to clickable elements (S1082)
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Add onKeyDown (Enter/Space) to the CommandPalette overlay backdrop div and
result item divs to satisfy SonarQube S1082. RouteFlow and ProcessorTimeline
already had the fixes in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:41:11 +02:00
hsiegeln
ba6028c2ea refactor: extract nested handlers to fix function depth violations (S2004)
All checks were successful
Build & Publish / publish (push) Successful in 1m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:35:46 +02:00
hsiegeln
93776944b9 refactor: extract keyboard handlers to reduce cognitive complexity (S3776)
Extract per-key arrow handler logic into standalone functions outside the
component in SidebarTree.tsx and TreeView.tsx, reducing handleKeyDown
cognitive complexity from 31 to below the 15-unit maximum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:35:27 +02:00
hsiegeln
9240acddb6 docs: update CLAUDE.md and COMPONENT_GUIDE.md for composable Sidebar
All checks were successful
Build & Publish / publish (push) Successful in 1m26s
- Add Sidebar, SidebarTree, useStarred to import paths
- Update navigation decision tree with compound component entries
- Replace old Sidebar props description with compound API
- Add standard page layout composition pattern for new Sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:30:27 +02:00
hsiegeln
912adb1070 feat: composable sidebar refactor
All checks were successful
Build & Publish / publish (push) Successful in 57s
Replaces monolithic Sidebar with compound component API:
Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer,
Sidebar.FooterLink. Exports SidebarTree, useStarred publicly.
Migrates mock app to LayoutShell with React Router layout routes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:16:30 +02:00
hsiegeln
eeb2713612 fix: strip Sidebar wrapper from RouteDetail + fix StatusDot prop in LayoutSection
- RouteDetail.tsx was missed in page stripping pass — remove AppShell
  + Sidebar wrapper, replace with fragment
- LayoutSection.tsx used StatusDot status= instead of variant=

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:13:03 +02:00
hsiegeln
18bf644040 refactor(inventory): update Sidebar demo to compound API
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:10:09 +02:00
hsiegeln
9fa7eb129d refactor: strip AppShell+Sidebar wrappers from all page components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:09:16 +02:00
hsiegeln
8cd3c3a99d refactor: wrap routes in LayoutShell layout route
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:05:49 +02:00
hsiegeln
36999941c0 feat(layout): create LayoutShell with compound Sidebar composition
Move all application-specific sidebar logic (tree builders, starred items,
section collapse state, sidebarReveal handling) out of the DS Sidebar into
a shared LayoutShell that wraps Outlet for route-level layout sharing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:04:08 +02:00
hsiegeln
5a91875723 test(sidebar): rewrite Sidebar tests for compound component API
Replace legacy monolithic Sidebar test suite with 16 tests covering the
new compound component API (Sidebar.Header, Section, Footer, FooterLink)
including icon-rail collapsed mode, search input visibility, and active
state highlighting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:01:39 +02:00
hsiegeln
c401516b2d fix(sidebar): add icon to expanded section, fix icon-rail callbacks, fix active border
- Render icon between chevron and label in expanded SidebarSection
- Remove !open guard from icon-rail click — always fire both callbacks
- Add border-left: 3px solid transparent to .treeSection so
  .treeSectionActive border-left-color takes effect
- Remove duplicate .treeSectionLabel CSS declaration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:59:25 +02:00
hsiegeln
d2c2b92183 feat(sidebar): update barrel exports for composable sidebar
Export SidebarTree, SidebarTreeNode, and useStarred from the layout
barrel. Remove old app-domain type exports (SidebarApp, SidebarRoute,
SidebarAgent) that no longer exist in the rewritten Sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:55:29 +02:00
hsiegeln
357e497220 feat(sidebar): update CSS for composable compound component
Add collapsed state styles (sidebarCollapsed, collapseToggle), icon-rail
mode (sectionRailItem, sectionIcon), and width transition. Remove old
monolithic classes (navArea, section, items, item, navIcon, routeArrow,
all starred-section styles). Pin footer with margin-top: auto.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:55:20 +02:00
hsiegeln
1173b3e363 feat(sidebar): rewrite Sidebar as composable compound component
Replace the monolithic Sidebar (560 lines of app-specific logic) with
a composable shell exposing Sidebar.Header, Sidebar.Section,
Sidebar.Footer, and Sidebar.FooterLink sub-components. Application
logic (tree builders, starred items, domain types) is removed — those
responsibilities move to the consuming app layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:54:18 +02:00
hsiegeln
7092271fdc feat(sidebar): add SidebarContext for composable sidebar
Create context and hook to share collapsed state and toggle callback
between compound component sub-components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:53:49 +02:00
hsiegeln
3561147b42 docs: add composable sidebar implementation plan
10-task plan covering compound component, CSS, exports, tests,
LayoutShell, route migration, and page wrapper stripping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:50:56 +02:00
hsiegeln
9afe626a58 docs: update composable sidebar spec with clarified decisions
Add searchValue prop for controlled input, SidebarContext for
collapsed state propagation, LayoutShell migration plan, and
icon-rail simultaneous callback behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:42:09 +02:00
hsiegeln
7758962564 docs: clarify search ownership and icon-rail click behavior
Search: DS renders dumb controlled input, app owns state and passes
filterQuery to SidebarTree instances. Icon-rail click: fires both
onCollapseToggle and onToggle simultaneously, no navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:41:36 +02:00
hsiegeln
4e2d5b2b2f docs: composable sidebar refactor spec
Compound component API replacing monolithic Sidebar. DS provides
shell (Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer,
Sidebar.FooterLink) + standalone SidebarTree and useStarred exports.
Application controls all content, icons, sections. Adds icon-rail
collapse mode. Breaking change — coordinate with server UI migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:38:04 +02:00
hsiegeln
af48bd2fa0 fix: sidebar highlighting works on all tabs, not just exchanges
All checks were successful
Build & Publish / publish (push) Successful in 1m0s
SonarQube Analysis / sonarqube (push) Successful in 2m17s
Remove the /exchanges/ path guard so sidebarRevealPath is used whenever
available, enabling correct sidebar selection on dashboard, runtime, and
all other tabs when navigating via Cmd-K.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:59:15 +02:00
hsiegeln
592b60c5fe feat: export Recharts theme preset for consuming apps
All checks were successful
Build & Publish / publish (push) Successful in 55s
SonarQube Analysis / sonarqube (push) Successful in 2m23s
Add rechartsTheme config object that maps design tokens to Recharts
component props, ensuring visual consistency without adding Recharts
as a dependency. Also export CHART_COLORS, ChartSeries, and DataPoint
types for public use. Document charting strategy in COMPONENT_GUIDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:31:02 +02:00
hsiegeln
0bb49b83e5 feat: DataTable scrollable layout with 200+ mock exchanges
All checks were successful
Build & Publish / publish (push) Successful in 1m55s
SonarQube Analysis / sonarqube (push) Successful in 1m52s
Make Dashboard table fill viewport height with sticky header/footer
and internal scrolling. Expand mock exchange data from 15 to 200
records and Inventory showcase from 5 to 500 records.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:43:03 +02:00
hsiegeln
8070fdea7c fix(ci): read SONAR_HOST_URL from secrets instead of vars
All checks were successful
Build & Publish / publish (push) Successful in 1m6s
SonarQube Analysis / sonarqube (push) Successful in 2m0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:38:09 +01:00
hsiegeln
7830ac5e0d fix(ci): validate SONAR_HOST_URL before running scanner
All checks were successful
Build & Publish / publish (push) Successful in 1m35s
Fail early with a clear message if the variable is missing or lacks
an http(s) scheme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:28:59 +01:00
hsiegeln
fdccca5378 fix(ci): detect arm64 arch for sonar-scanner download
All checks were successful
Build & Publish / publish (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:21:31 +01:00
hsiegeln
0d4215678a fix(ci): use native sonar-scanner CLI instead of npm wrapper
All checks were successful
Build & Publish / publish (push) Successful in 1m11s
The npm sonarqube-scanner bootstrapper was ignoring the host URL and
defaulting to sonarcloud.io. Switch to the official sonar-scanner-cli
binary which respects -D flags directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:15:49 +01:00
hsiegeln
28690b2a7a fix(ci): add @vitest/coverage-v8 for SonarQube workflow
All checks were successful
Build & Publish / publish (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:12:45 +01:00
hsiegeln
5eb807c572 ci: add nightly SonarQube analysis workflow
All checks were successful
Build & Publish / publish (push) Successful in 1m40s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:59:24 +01:00
hsiegeln
f359a2ba3d feat: add Sidebar onNavigate callback and DataTable fillHeight prop
All checks were successful
Build & Publish / publish (push) Successful in 1m3s
Sidebar: add optional onNavigate prop so consuming apps can intercept
and remap navigation paths instead of relying on internal React Router
links.

DataTable: add fillHeight prop for flex-fill layouts with scrolling
body. Make the table header sticky by default so it stays visible
during vertical scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:28:49 +01:00
hsiegeln
384ee97643 chore: npm audit fix
All checks were successful
Build & Publish / publish (push) Successful in 1m39s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:33:37 +01:00
hsiegeln
a12b374fb2 feat: add onSubmit prop to CommandPalette for full-text search
All checks were successful
Build & Publish / publish (push) Successful in 58s
When Enter is pressed without explicit arrow/mouse navigation,
fires onSubmit with the raw query instead of selecting the
focused result. Enables using the palette as a search filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:30:36 +01:00
hsiegeln
433d582da6 feat: migrate all icons to Lucide React
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Replace unicode characters, emoji, and inline SVGs with lucide-react
components across the entire design system and page layer. Update
tests to assert on SVG elements instead of text content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:25:43 +01:00
hsiegeln
2ffc268b44 feat: add attribute search category to CommandPalette
All checks were successful
Build & Publish / publish (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:03:57 +01:00
hsiegeln
99ae66315b feat: add trace log level to LogViewer
All checks were successful
Build & Publish / publish (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:04:50 +01:00
hsiegeln
26de5ec58f feat: add click-to-close backdrop behind DetailPanel
All checks were successful
Build & Publish / publish (push) Successful in 58s
Adds a subtle semi-transparent backdrop (rgba(0,0,0,0.15)) behind the
overlay panel. Clicking the backdrop closes the panel. Fades in with
the panel animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:57:34 +01:00
hsiegeln
d26dc6a8a5 fix: make DetailPanel overlay instead of taking flex space
All checks were successful
Build & Publish / publish (push) Successful in 1m1s
DetailPanel now uses position: fixed to overlay the content area rather
than participating in the AppShell flex row. This prevents the TopBar
from being compressed when the panel opens. Added box-shadow for depth
separation and z-index: 100 for stacking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:55:02 +01:00
hsiegeln
c0b1cbdc5b fix: remove inset background from LogViewer container
All checks were successful
Build & Publish / publish (push) Successful in 1m30s
LogViewer was using --bg-inset which created a visual mismatch with the
EventFeed timeline panel. Now inherits the parent card's background
(--bg-surface) for consistent appearance in side-by-side layouts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:24:35 +01:00
hsiegeln
d101d883a9 fix: auto-scroll to top for EventFeed and LogViewer
All checks were successful
Build & Publish / publish (push) Successful in 52s
Newest entries appear at the top in descending sort, so auto-scroll
should snap to top instead of bottom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:45:30 +01:00
hsiegeln
2a020c1e15 feat: add BreadcrumbProvider and e2e test suite
All checks were successful
Build & Publish / publish (push) Successful in 53s
Add BreadcrumbProvider context so pages can override TopBar breadcrumbs
dynamically. Add Playwright e2e tests for dashboard, agents, routes,
exchanges, and admin pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:33:55 +01:00
hsiegeln
19303eefad feat: auto-slide time range forward when preset is active with auto-refresh
All checks were successful
Build & Publish / publish (push) Successful in 50s
Adds a 10s interval that recomputes the preset time range boundaries
so dashboards stay current without manual refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:54:24 +01:00
hsiegeln
5fe6321d30 feat: add multi-flow support to RouteFlow via flows prop
All checks were successful
Build & Publish / publish (push) Successful in 50s
Allow rendering multiple named flow segments (e.g., main route +
onException handler) within a single RouteFlow component. Each segment
gets a labeled section with variant-based color theming. Fully backward
compatible — existing nodes prop continues to work unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:10:27 +01:00
hsiegeln
90e3de2cdf feat: fix category counts and add matchContext for search highlights
All checks were successful
Build & Publish / publish (push) Successful in 58s
- Category tab counts now reflect query-filtered results, not total data
- Added matchContext field to SearchResult for server-side match snippets
- Renders <em>-tagged highlight text below meta in muted style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:26:55 +01:00
hsiegeln
499c86b680 feat: add onQueryChange prop and serverFiltered flag to CommandPalette
All checks were successful
Build & Publish / publish (push) Successful in 51s
Enables server-side exchange search from the command palette. Server-filtered
results skip client-side text matching since they matched on content not
visible in title/meta.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:51:16 +01:00
hsiegeln
63e16d2685 feat: add generic badge system to RouteFlow and ProcessorTimeline
All checks were successful
Build & Publish / publish (push) Successful in 49s
New NodeBadge interface with variant colors (info/success/warning/error)
and optional onClick. Replaces single-purpose bottleneckBadge with a
flexible badges array. Backwards compatible: isBottleneck still works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:06:55 +01:00
hsiegeln
19dccb8685 feat: add registerable actions to ProcessorTimeline and RouteFlow
All checks were successful
Build & Publish / publish (push) Successful in 51s
Both components now accept `actions` (static) or `getActions` (dynamic
per-item) props. When provided, a "⋮" trigger appears on hover that
opens a Dropdown menu. Click propagation is stopped so action clicks
don't fire the row/node click handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:49:07 +01:00
hsiegeln
4b873194c9 fix(ci): add --allow-same-version to npm version in publish workflow
All checks were successful
Build & Publish / publish (push) Successful in 51s
The tag push job fails with "Version not changed" when package.json
already has the correct version from the bump commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:05:08 +01:00
hsiegeln
5f1b039056 chore: bump version to 0.1.6
Some checks failed
Build & Publish / publish (push) Failing after 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:01:53 +01:00
hsiegeln
095abe1751 feat: self-portaling DetailPanel via AppShell portal target
Some checks failed
Build & Publish / publish (push) Failing after 50s
DetailPanel now uses createPortal to render itself into
#cameleer-detail-panel-root, a div that AppShell places as a
direct sibling of .main in the top-level flex row. This means
pages can render <DetailPanel> anywhere in their JSX and it
automatically appears at the correct layout position.

AppShell's detail prop is deprecated and ignored — the portal
handles positioning automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:58:06 +01:00
hsiegeln
e8859e53ce chore: sync package-lock.json
All checks were successful
Build & Publish / publish (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:12:21 +01:00
hsiegeln
021f6c7811 chore: bump version to 0.1.3
Some checks failed
Build & Publish / publish (push) Failing after 6s
2026-03-24 18:04:51 +01:00
144 changed files with 7084 additions and 1806 deletions

View File

@@ -15,7 +15,21 @@
"Bash(echo \"EXIT:$?\")",
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
"Bash(echo \"EXIT_CODE=$?\")",
"Bash(echo \"EXIT=$?\")"
"Bash(echo \"EXIT=$?\")",
"mcp__gitea__actions_config_read",
"mcp__gitea__search_repos",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")",
"Bash(npx vite:*)",
"Bash(cd:*)",
"mcp__gitea__actions_run_read",
"mcp__gitea__get_file_contents",
"WebFetch(domain:ui.shadcn.com)",
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")",
"Bash(npm test:*)",
"Bash(grep:*)",
"Bash(xargs cat:*)"
]
}
}

View File

@@ -28,7 +28,7 @@ jobs:
case "$GITHUB_REF" in
refs/tags/v*)
VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version
npm version "$VERSION" --no-git-tag-version --allow-same-version
TAG="latest"
;;
*)

View File

@@ -0,0 +1,62 @@
name: SonarQube Analysis
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npx vitest run --exclude 'e2e/**' --coverage --coverage.reporter=lcov
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Install sonar-scanner
run: |
SONAR_SCANNER_VERSION=6.2.1.4610
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
PLATFORM="linux-aarch64"
else
PLATFORM="linux-x64"
fi
curl -sSLo sonar-scanner.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-${PLATFORM}.zip"
unzip -q sonar-scanner.zip
echo "$PWD/sonar-scanner-${SONAR_SCANNER_VERSION}-${PLATFORM}/bin" >> "$GITHUB_PATH"
- name: Run SonarQube analysis
env:
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
if [ -z "$SONAR_HOST_URL" ] || ! echo "$SONAR_HOST_URL" | grep -qE '^https?://'; then
echo "::error::SONAR_HOST_URL is missing or invalid (got: '$SONAR_HOST_URL'). Set it as a repo variable with full URL (e.g. https://sonar.example.com)."
exit 1
fi
sonar-scanner \
-Dsonar.host.url="$SONAR_HOST_URL" \
-Dsonar.login="$SONAR_TOKEN" \
-Dsonar.projectKey=cameleer-design-system \
-Dsonar.projectName="Cameleer Design System" \
-Dsonar.sources=src/design-system \
-Dsonar.tests=src/design-system \
-Dsonar.test.inclusions="**/*.test.tsx,**/*.test.ts" \
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info \
-Dsonar.exclusions="**/node_modules/**,**/dist/**"

4
.gitignore vendored
View File

@@ -2,3 +2,7 @@ node_modules/
dist/
.superpowers/
.worktrees/
test-results/
screenshots/
.playwright-mcp/
.gitnexus

123
CLAUDE.md
View File

@@ -40,6 +40,10 @@ import { Button, Input } from '../design-system/primitives'
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell'
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
```
@@ -93,6 +97,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
// All components from single entry
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
// Sidebar (compound component — compose your own navigation)
import { Sidebar, SidebarTree, useStarred } from '@cameleer/design-system'
import type { SidebarTreeNode } from '@cameleer/design-system'
// Types
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
@@ -104,6 +112,121 @@ import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
// Utils
import { hashColor } from '@cameleer/design-system'
// Recharts theme (for advanced charts — treemap, radar, heatmap, etc.)
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
import type { ChartSeries, DataPoint } from '@cameleer/design-system'
// Styles (once, at app root)
import '@cameleer/design-system/style.css'
// Brand assets (static files via ./assets/* export)
import logo from '@cameleer/design-system/assets/cameleer3-logo.png' // full resolution
import logo32 from '@cameleer/design-system/assets/cameleer3-32.png' // 32×32 favicon
import logo180 from '@cameleer/design-system/assets/cameleer3-180.png' // Apple touch icon
import logo192 from '@cameleer/design-system/assets/cameleer3-192.png' // Android/PWA icon
import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA splash, og:image
import logoSvg from '@cameleer/design-system/assets/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 -->

View File

@@ -38,10 +38,12 @@
- Removable label → **Tag**
### "I need navigation"
- App-level sidebar nav → **Sidebar** (via AppShell) — hierarchical trees with starring
- App-level sidebar nav → **Sidebar** (compound component — compose sections, trees, footer links)
- Sidebar tree section → **SidebarTree** (data-driven tree with expand/collapse, starring, keyboard nav)
- Starred items persistence → **useStarred** hook (localStorage-backed)
- Breadcrumb trail → **Breadcrumb**
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
- Hierarchical tree navigation → **TreeView** (generic) or **SidebarTree** (sidebar-specific, internal)
- Hierarchical tree navigation → **TreeView** (generic)
### "I need floating content"
- Tooltip on hover → **Tooltip**
@@ -55,6 +57,7 @@
- Time series → **LineChart**, **AreaChart**
- Categorical comparison → **BarChart**
- Inline trend → **Sparkline**
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
- Event log → **EventFeed**
- Processing pipeline (Gantt view) → **ProcessorTimeline**
- Processing pipeline (flow diagram) → **RouteFlow**
@@ -98,7 +101,24 @@
### Standard page layout
```
AppShell → Sidebar + TopBar + main content + optional DetailPanel
AppShell → Sidebar (compound) + TopBar + main content + optional DetailPanel
Sidebar compound API:
<Sidebar collapsed={bool} onCollapseToggle={fn} searchValue={str} onSearchChange={fn}>
<Sidebar.Header logo={node} title="str" version="str" />
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} active={bool}>
<SidebarTree nodes={[...]} selectedPath="..." filterQuery="..." ... />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={node} label="str" onClick={fn} active={bool} />
</Sidebar.Footer>
</Sidebar>
Notes:
- Search input auto-renders between Header and first Section (not above Header)
- Section headers have no chevron — the entire row is clickable to toggle
- The app controls all content — sections, order, tree data, collapse state
- Sidebar provides the frame, search input, and icon-rail collapse mode
```
### Data page pattern
@@ -160,6 +180,53 @@ StatCard strip (top, recalculates per scope)
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
```
## Charting Strategy
The design system includes built-in **AreaChart**, **BarChart**, **LineChart**, and **Sparkline** components for standard use cases. For advanced chart types (treemap, radar, heatmap, pie, sankey, etc.), consuming apps should use **Recharts** directly with the design system's theme preset for visual consistency.
**Recharts is the app's dependency, not the design system's.** The design system only exports a theme config object.
### Setup in consuming app
```bash
npm install recharts
```
### Usage with theme preset
```tsx
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
import {
ResponsiveContainer, LineChart, Line,
CartesianGrid, XAxis, YAxis, Tooltip, Legend,
} from 'recharts'
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid {...rechartsTheme.cartesianGrid} />
<XAxis dataKey="name" {...rechartsTheme.xAxis} />
<YAxis {...rechartsTheme.yAxis} />
<Tooltip {...rechartsTheme.tooltip} />
<Legend {...rechartsTheme.legend} />
<Line dataKey="value" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
<Line dataKey="other" stroke={CHART_COLORS[1]} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
```
### Exports
| Export | Description |
|--------|-------------|
| `rechartsTheme.cartesianGrid` | Dashed gridlines, subtle stroke |
| `rechartsTheme.xAxis` | Mono font axis ticks, subtle color |
| `rechartsTheme.yAxis` | Mono font axis ticks, no axis line |
| `rechartsTheme.tooltip` | Surface bg, border, shadow, monospace values |
| `rechartsTheme.legend` | Matching text size and color |
| `rechartsTheme.colors` | The 8 `CHART_COLORS` (CSS variables with light/dark support) |
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
| `ChartSeries` / `DataPoint` | Type interfaces for chart data |
## Component Index
| Component | Layer | When to use |
@@ -179,7 +246,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
| Collapsible | primitive | Single expand/collapse section |
| CommandPalette | composite | Full-screen search and command interface |
| CommandPalette | composite | Full-screen search and command interface. `SearchCategory` is an open `string` type — known categories (application, exchange, attribute, route, agent) have built-in labels; custom categories render with title-cased labels and appear as dynamic tabs. |
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
| DateRangePicker | primitive | Date range selection with presets |
@@ -207,8 +274,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| MonoText | primitive | Inline monospace text (xs, sm, md) |
| Pagination | primitive | Page navigation controls |
| Popover | composite | Click-triggered floating panel with arrow |
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. |
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. |
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
| RadioItem | primitive | Individual radio option within RadioGroup |
@@ -236,8 +303,10 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Component | Purpose |
|-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
## Import Paths
@@ -248,6 +317,10 @@ import { Button, Input, Badge } from './design-system/primitives'
import { DataTable, Modal, Toast } from './design-system/composites'
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
import { AppShell } from './design-system/layout/AppShell'
import { Sidebar } from './design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from './design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from './design-system/layout/Sidebar/SidebarTree'
import { useStarred } from './design-system/layout/Sidebar/useStarred'
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
```
@@ -261,6 +334,35 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
## Brand Assets
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
| File | Size | Use case |
|------|------|----------|
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
| `cameleer3-48.png` | 48×48 | Windows taskbar |
| `cameleer3-180.png` | 180×180 | Apple touch icon |
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
| `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
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)

3
assets/camel-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
assets/cameleer3-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

BIN
assets/cameleer3-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/cameleer3-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/cameleer3-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/cameleer3-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
assets/cameleer3-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
assets/cameleer3-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

112
assets/cameleer3-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,399 @@
# Composable Sidebar Refactor
**Date:** 2026-04-02
**Upstream issue:** cameleer3-server #112
## Why
The current `Sidebar` component is monolithic. It hardcodes three navigation sections (Applications, Agents, Routes), a starred items section, bottom links (Admin, API Docs), and all tree-building logic (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`). The consuming application can only pass `SidebarApp[]` data — it cannot control what sections exist, what order they appear in, or add new sections without modifying this package.
This blocks two features the consuming application needs:
1. **Admin accordion** — when the user enters admin context, the sidebar should expand an Admin section and collapse operational sections, all controlled by the application
2. **Icon-rail collapse** — the sidebar should collapse to a narrow icon strip, like modern app sidebars (Linear, VS Code, etc.)
## Goal
Refactor `Sidebar` into a composable compound component. The DS provides the frame and building blocks. The consuming application controls all content.
## Current Exports (to be replaced)
```typescript
// Current — monolithic
export { Sidebar } from './Sidebar/Sidebar'
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
```
## New Exports
```typescript
// New — composable
export { Sidebar } from './Sidebar/Sidebar'
export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred'
```
`SidebarApp`, `SidebarRoute`, `SidebarAgent` types are removed — they are application-domain types that move to the consuming app.
## Compound Component API
### `<Sidebar>`
The outer shell. Renders the sidebar frame with an optional search input and collapse toggle.
```tsx
<Sidebar
collapsed={false}
onCollapseToggle={() => {}}
searchValue=""
onSearchChange={(query) => {}}
className=""
>
<Sidebar.Header ... />
<Sidebar.Section ... />
<Sidebar.Section ... />
<Sidebar.Footer ... />
</Sidebar>
```
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `collapsed` | `boolean` | `false` | Render as ~48px icon rail |
| `onCollapseToggle` | `() => void` | - | Collapse/expand toggle clicked |
| `onSearchChange` | `(query: string) => void` | - | Search input changed. Omit to hide search. |
| `searchValue` | `string` | `''` | Controlled value for the search input |
| `children` | `ReactNode` | - | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
| `className` | `string` | - | Additional CSS class |
**Search state ownership:** The DS renders the search input as a dumb controlled input and calls `onSearchChange` on every keystroke. The consuming application owns the search state and passes it to each `SidebarTree` as `filterQuery`. This lets the app control filtering behavior (e.g., clear search when switching sections, filter only certain sections). The DS does not hold any search state internally.
**Rendering rules:**
- Expanded: full width (~260px), all content visible
- Collapsed: ~48px wide, only icons visible, tooltips on hover
- Width transition: `transition: width 200ms ease`
- Collapse toggle button (`<<` / `>>` chevron) in top-right corner
- Search input hidden when collapsed
- Search input auto-positioned between `Sidebar.Header` and first `Sidebar.Section` (not above Header)
### `<Sidebar.Header>`
Logo, title, and version. In collapsed mode, renders only the logo centered.
```tsx
<Sidebar.Header
logo={<img src="..." />}
title="cameleer"
version="v3.2.1"
/>
```
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `logo` | `ReactNode` | - | Logo element |
| `title` | `string` | - | App name (hidden when collapsed) |
| `version` | `string` | - | Version text (hidden when collapsed) |
### `<Sidebar.Section>`
An accordion section with a collapsible header and content area.
```tsx
<Sidebar.Section
label="APPLICATIONS"
icon={<Box size={14} />}
collapsed={false}
onToggle={() => {}}
active={false}
>
<SidebarTree nodes={nodes} ... />
</Sidebar.Section>
```
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | - | Section header text (rendered uppercase via CSS) |
| `icon` | `ReactNode` | - | Icon for header and collapsed rail |
| `collapsed` | `boolean` | `false` | Whether children are hidden |
| `onToggle` | `() => void` | - | Header clicked |
| `children` | `ReactNode` | - | Content when expanded |
| `active` | `boolean` | - | Override active highlight. If omitted, not highlighted. |
**Expanded rendering:**
```
[icon] APPLICATIONS
(children rendered here)
```
**Collapsed rendering:**
```
[icon] APPLICATIONS
```
**In sidebar icon-rail mode:**
```
[icon] <- centered, tooltip shows label on hover
```
Header has: icon and label (no chevron — the entire row is clickable). Active section gets the amber left-border accent (existing pattern). Clicking anywhere on the header row calls `onToggle`.
**Implementation detail:** `Sidebar.Section` and `Sidebar.Header` need to know the parent's `collapsed` state to switch between expanded and icon-rail rendering. The `<Sidebar>` component provides `collapsed` and `onCollapseToggle` via React context (`SidebarContext`). Sub-components read from context — no prop drilling needed.
**Icon-rail click behavior:** In collapsed mode, clicking a section icon fires both `onCollapseToggle` and `onToggle` simultaneously on the same click. The sidebar expands and the section opens in one motion. No navigation occurs — the user is expanding the sidebar to see what's inside, not committing to a destination. They click a tree item after the section is visible to navigate.
### `<Sidebar.Footer>`
Pinned to the bottom of the sidebar. Container for `Sidebar.FooterLink` items.
```tsx
<Sidebar.Footer>
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => {}} />
</Sidebar.Footer>
```
In collapsed mode, footer links render as centered icons with tooltips.
### `<Sidebar.FooterLink>`
A single bottom link.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `icon` | `ReactNode` | - | Link icon |
| `label` | `string` | - | Link text (hidden when collapsed, shown as tooltip) |
| `onClick` | `() => void` | - | Click handler |
| `active` | `boolean` | `false` | Active state highlight |
### `<SidebarTree>` (no changes, newly exported)
Already exists at `Sidebar/SidebarTree.tsx`. No modifications needed — it already accepts all data via props. Just export it from the package.
**Current props (unchanged):**
| Prop | Type | Description |
|------|------|-------------|
| `nodes` | `SidebarTreeNode[]` | Tree data |
| `selectedPath` | `string` | Currently active path for highlighting |
| `filterQuery` | `string` | Search filter text |
| `onNavigate` | `(path: string) => void` | Navigation callback |
| `persistKey` | `string` | localStorage key for expand state |
| `autoRevealPath` | `string \| null` | Path to auto-expand to |
| `isStarred` | `(id: string) => boolean` | Star state checker |
| `onToggleStar` | `(id: string) => void` | Star toggle callback |
### `useStarred` hook (no changes, newly exported)
Already exists at `Sidebar/useStarred.ts`. Export as-is.
**Returns:** `{ starredIds, isStarred, toggleStar }`
## What Gets Removed
All of this application-specific logic is deleted from the DS:
1. **`buildAppTreeNodes()`** (~30 lines) — transforms `SidebarApp[]` into `SidebarTreeNode[]`
2. **`buildRouteTreeNodes()`** (~20 lines) — transforms apps into route tree nodes
3. **`buildAgentTreeNodes()`** (~25 lines) — transforms apps into agent tree nodes with live-count badges
4. **`collectStarredItems()`** (~20 lines) — gathers starred items across types
5. **`StarredGroup`** sub-component (~30 lines) — renders grouped starred items
6. **Hardcoded sections** (~100 lines) — Applications, Agents, Routes section rendering with localStorage persistence
7. **Hardcoded bottom links** (~30 lines) — Admin and API Docs links
8. **Auto-reveal effect** (~20 lines) — `sidebarRevealPath` effect
9. **`SidebarApp`, `SidebarRoute`, `SidebarAgent` types** — domain types, not DS types
10. **`formatCount()` helper** — number formatting, moves to consuming app
Total: ~300 lines of application logic removed, replaced by ~150 lines of compound component shell.
## CSS Changes
### New styles needed
- `.sidebarCollapsed` — narrow width (48px), centered icons
- `.collapseToggle``<<` / `>>` button positioning
- `.sectionIcon` — icon rendering in section headers
- `.tooltip` — hover tooltips for collapsed mode
- Width transition: `transition: width 200ms ease` on `.sidebar`
### Styles that stay
- `.sidebar` (modified: width becomes conditional)
- `.searchWrap`, `.searchInput` (unchanged)
- `.navArea` (unchanged)
- All tree styles in `SidebarTree` (unchanged)
### Styles removed
- `.bottom`, `.bottomItem`, `.bottomItemActive` — replaced by `Sidebar.Footer` / `Sidebar.FooterLink` styles
- `.starredSection`, `.starredGroup`, `.starredItem`, `.starredRemove` — starred rendering moves to app
- `.section` — replaced by `Sidebar.Section` styles
## File Structure After Refactor
```
Sidebar/
├── Sidebar.tsx # Compound component: Sidebar, Sidebar.Header,
│ # Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink
├── Sidebar.module.css # Updated styles (shell + section + footer + collapsed)
├── SidebarTree.tsx # Unchanged
├── SidebarTree.module.css # Unchanged (if separate, otherwise stays in Sidebar.module.css)
├── useStarred.ts # Unchanged
├── useStarred.test.ts # Unchanged
└── Sidebar.test.tsx # Updated for new compound API
```
## Testing
Update `Sidebar.test.tsx` to test the compound component API:
- Renders Header with logo, title, version
- Renders Sections with labels and icons
- Section toggle calls `onToggle`
- Collapsed sections hide children
- Sidebar collapsed mode renders icon rail
- Collapse toggle calls `onCollapseToggle`
- Footer links render with icons and labels
- Collapsed mode hides labels, shows tooltips
- Search input calls `onSearchChange`
- Search hidden when sidebar collapsed
- Section icon click in collapsed mode calls both `onCollapseToggle` and `onToggle`
`SidebarTree` tests are unaffected.
## Usage Example (for reference)
This is how the consuming application (cameleer3-server) will use the new API. This code does NOT live in the design system — it's shown for context only.
```tsx
// In LayoutShell.tsx (consuming app)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [filterQuery, setFilterQuery] = useState('');
const [appsCollapsed, setAppsCollapsed] = useState(false);
const [agentsCollapsed, setAgentsCollapsed] = useState(false);
const [routesCollapsed, setRoutesCollapsed] = useState(true);
const [adminCollapsed, setAdminCollapsed] = useState(true);
// Accordion: entering admin expands admin, collapses others
useEffect(() => {
if (isAdminPage) {
setAdminCollapsed(false);
setAppsCollapsed(true);
setAgentsCollapsed(true);
setRoutesCollapsed(true);
} else {
setAdminCollapsed(true);
// restore previous operational states
}
}, [isAdminPage]);
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header logo={<CameleerLogo />} title="cameleer" version="v3.2.1" />
{isAdminPage && (
<Sidebar.Section label="ADMIN" icon={<Settings size={14} />}
collapsed={adminCollapsed} onToggle={() => setAdminCollapsed(v => !v)}>
<SidebarTree nodes={adminNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
)}
<Sidebar.Section label="APPLICATIONS" icon={<Box size={14} />}
collapsed={appsCollapsed} onToggle={() => { setAppsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={appNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section label="AGENTS" icon={<Cpu size={14} />}
collapsed={agentsCollapsed} onToggle={() => { setAgentsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={agentNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section label="ROUTES" icon={<GitBranch size={14} />}
collapsed={routesCollapsed} onToggle={() => { setRoutesCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={routeNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
</Sidebar.Footer>
</Sidebar>
```
## Mock App Migration — LayoutShell
The 11 page files currently duplicating `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>` will be consolidated into a single `LayoutShell` component.
### `src/layout/LayoutShell.tsx`
Composes the sidebar once using the new compound API. All page-specific content is rendered via `<Outlet />`.
```tsx
// src/layout/LayoutShell.tsx
export function LayoutShell() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [filterQuery, setFilterQuery] = useState('')
const [appsCollapsed, setAppsCollapsed] = useState(false)
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
const [routesCollapsed, setRoutesCollapsed] = useState(false)
const { starredIds, isStarred, toggleStar } = useStarred()
const location = useLocation()
// ... build tree nodes from SIDEBAR_APPS, starred section, etc.
return (
<AppShell
sidebar={
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header logo={...} title="cameleer" version="v3.2.1" />
<Sidebar.Section label="Applications" icon={...}
collapsed={appsCollapsed} onToggle={() => setAppsCollapsed(v => !v)}>
<SidebarTree nodes={appNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
<Sidebar.Section label="Agents" icon={...}
collapsed={agentsCollapsed} onToggle={() => setAgentsCollapsed(v => !v)}>
<SidebarTree nodes={agentNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
<Sidebar.Section label="Routes" icon={...}
collapsed={routesCollapsed} onToggle={() => setRoutesCollapsed(v => !v)}>
<SidebarTree nodes={routeNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
{/* Starred section built from useStarred + SIDEBAR_APPS */}
<Sidebar.Footer>
<Sidebar.FooterLink icon={...} label="Admin" ... />
<Sidebar.FooterLink icon={...} label="API Docs" ... />
</Sidebar.Footer>
</Sidebar>
}
>
<Outlet />
</AppShell>
)
}
```
### Route structure change
`App.tsx` switches from per-page `<Route element={<Page />}>` to a layout route:
```tsx
<Route element={<LayoutShell />}>
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
...all existing routes...
</Route>
```
All tree-building helpers (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`), starred section logic (`collectStarredItems`, `StarredGroup`), `formatCount`, and `sidebarRevealPath` handling move from `Sidebar.tsx` into `LayoutShell.tsx`. Each page file loses its `<AppShell sidebar={...}>` wrapper and becomes just the page content.
The Inventory page's `LayoutSection` keeps its own inline `<Sidebar>` demo with `SAMPLE_APPS` data — it's a showcase, not a navigation shell.
## Breaking Change
This is a **breaking change** to the `Sidebar` API. The old `<Sidebar apps={[...]} onNavigate={...} />` signature is removed entirely. The consuming application must migrate to the compound component API in the same release cycle.
Coordinate: bump DS version, update server UI, deploy together.

165
e2e/admin.spec.ts Normal file
View File

@@ -0,0 +1,165 @@
import { test, expect } from '@playwright/test'
test.describe('Admin - User Management (/admin/rbac)', () => {
test('renders admin tabs and user table', async ({ page }) => {
await page.goto('/admin/rbac')
// Admin navigation tabs
await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible()
// User Management sub-tabs
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible()
// TopBar breadcrumb
await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible()
})
test('switching between Users, Groups, and Roles tabs', async ({ page }) => {
await page.goto('/admin/rbac')
// Default tab is Users
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
// Switch to Groups tab
await page.getByRole('tab', { name: 'Groups' }).click()
await page.waitForTimeout(300)
// Switch to Roles tab
await page.getByRole('tab', { name: 'Roles' }).click()
await page.waitForTimeout(300)
// Switch back to Users
await page.getByRole('tab', { name: 'Users' }).click()
})
test('navigating between admin sections via tabs', async ({ page }) => {
await page.goto('/admin/rbac')
// Click Audit Log tab
await page.getByRole('tab', { name: 'Audit Log' }).click()
await expect(page).toHaveURL(/\/admin\/audit/)
// Click OIDC tab
await page.getByRole('tab', { name: 'OIDC' }).click()
await expect(page).toHaveURL(/\/admin\/oidc/)
// Back to User Management
await page.getByRole('tab', { name: 'User Management' }).click()
await expect(page).toHaveURL(/\/admin\/rbac/)
})
})
test.describe('Admin - Audit Log (/admin/audit)', () => {
test('renders audit table with filters', async ({ page }) => {
await page.goto('/admin/audit')
// Table headers
await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible()
// Table has data
const rows = page.locator('table tbody tr')
expect(await rows.count()).toBeGreaterThan(0)
// Filter inputs exist
await expect(page.getByPlaceholder('Filter by user...')).toBeVisible()
await expect(page.getByPlaceholder('Search action or target...')).toBeVisible()
})
test('filtering audit events by search', async ({ page }) => {
await page.goto('/admin/audit')
const searchInput = page.getByPlaceholder('Search action or target...')
await searchInput.fill('deploy')
// Table should update
await page.waitForTimeout(300)
const rows = page.locator('table tbody tr')
const count = await rows.count()
expect(count).toBeGreaterThanOrEqual(0)
})
})
test.describe('Admin - OIDC Config (/admin/oidc)', () => {
test('renders OIDC form with all fields', async ({ page }) => {
await page.goto('/admin/oidc')
// Section headers
await expect(page.getByText('Behavior')).toBeVisible()
await expect(page.getByText('Provider Settings')).toBeVisible()
await expect(page.getByText('Claim Mapping')).toBeVisible()
await expect(page.getByText('Default Roles')).toBeVisible()
await expect(page.getByText('Danger Zone')).toBeVisible()
// Form fields by id
await expect(page.locator('#issuer')).toBeVisible()
await expect(page.locator('#client-id')).toBeVisible()
await expect(page.locator('#client-secret')).toBeVisible()
await expect(page.locator('#roles-claim')).toBeVisible()
await expect(page.locator('#name-claim')).toBeVisible()
// Buttons
await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible()
// Default roles tags
await expect(page.getByText('USER').first()).toBeVisible()
await expect(page.getByText('VIEWER').first()).toBeVisible()
})
test('toggling Enabled switch', async ({ page }) => {
await page.goto('/admin/oidc')
// The Toggle's checkbox is visually hidden — click the label wrapper instead
const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' })
await enabledLabel.click()
// Should not crash; label still visible
await expect(enabledLabel).toBeVisible()
})
test('adding and removing a role tag', async ({ page }) => {
await page.goto('/admin/oidc')
// Add a new role
const roleInput = page.getByPlaceholder('Add role...')
await roleInput.fill('EDITOR')
// Use the Add button next to the input (scoped to same row)
await roleInput.press('Enter')
// New role tag should appear
await expect(page.getByText('EDITOR')).toBeVisible()
// Remove it via aria-label on the tag's remove button
await page.getByRole('button', { name: 'Remove EDITOR' }).click()
// EDITOR tag should be gone
await expect(page.getByText('EDITOR')).not.toBeVisible()
})
test('Save button shows success toast', async ({ page }) => {
await page.goto('/admin/oidc')
await page.getByRole('button', { name: 'Save' }).click()
// Toast notification
await expect(page.getByText('Settings saved')).toBeVisible()
})
test('Delete button shows confirmation dialog', async ({ page }) => {
await page.goto('/admin/oidc')
await page.getByRole('button', { name: /Delete OIDC/i }).click()
// Confirmation dialog should appear
await expect(page.getByText('Delete OIDC configuration?')).toBeVisible()
})
})

80
e2e/agents.spec.ts Normal file
View File

@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test'
test.describe('Agent Health (/agents)', () => {
test('renders stat cards and group cards', async ({ page }) => {
await page.goto('/agents')
// Stat strip
await expect(page.getByText('Total Agents')).toBeVisible()
await expect(page.getByText('Total TPS')).toBeVisible()
// Group cards for each application
await expect(page.getByText('order-service').first()).toBeVisible()
await expect(page.getByText('payment-svc').first()).toBeVisible()
await expect(page.getByText('notification-hub').first()).toBeVisible()
// Instance tables have data
const instanceRows = page.locator('table tbody tr')
expect(await instanceRows.count()).toBeGreaterThan(0)
// Instance table headers
await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible()
// Timeline section
await expect(page.getByText('Timeline').first()).toBeVisible()
})
test('clicking an instance row opens the detail panel', async ({ page }) => {
await page.goto('/agents')
// Click first instance row
const instanceRow = page.locator('table tbody tr').first()
await instanceRow.click()
// Detail panel opens — look for detail-specific labels
await expect(page.getByText('Version').first()).toBeVisible()
await expect(page.getByText('Throughput').first()).toBeVisible()
})
test('detail panel has Performance tab with charts', async ({ page }) => {
await page.goto('/agents')
// Click an instance to open detail panel
const instanceRow = page.locator('table tbody tr').first()
await instanceRow.click()
// Wait for panel to open
await expect(page.getByText('Version').first()).toBeVisible()
// DetailPanel tabs are plain buttons (not role="tab")
// Switch to Performance tab
const perfTab = page.getByRole('button', { name: 'Performance' })
await perfTab.click()
// Performance charts should render
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible()
})
test('app-scoped agents view', async ({ page }) => {
await page.goto('/agents/order-service')
// Breadcrumb/scope shows app
await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible()
// Only order-service agents should show
await expect(page.getByText('ord-1').first()).toBeVisible()
await expect(page.getByText('ord-2').first()).toBeVisible()
await expect(page.getByText('ord-3').first()).toBeVisible()
})
test('dead agent shows alert banner', async ({ page }) => {
await page.goto('/agents')
// notification-hub has a dead instance, should show alert
await expect(page.getByText('Single point of failure')).toBeVisible()
})
})

90
e2e/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test'
/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */
async function widenTimeRange(page: import('@playwright/test').Page) {
await page.getByRole('tab', { name: '7d' }).click()
}
test.describe('Dashboard (/apps)', () => {
test('renders KPI stat cards and exchange table', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// KPI health strip renders
await expect(page.getByText('Recent Exchanges')).toBeVisible()
// Table headers
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible()
// Table has data rows
const rows = page.locator('table tbody tr')
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThan(0)
// Sidebar renders with app names
await expect(page.getByText('order-service').first()).toBeVisible()
await expect(page.getByText('payment-svc').first()).toBeVisible()
// TopBar renders
await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible()
await expect(page.getByText('PRODUCTION')).toBeVisible()
// Shortcuts bar
await expect(page.getByText('Ctrl+K').first()).toBeVisible()
})
test('clicking a table row opens the detail panel', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// Click the first data row
const firstRow = page.locator('table tbody tr').first()
await expect(firstRow).toBeVisible()
await firstRow.click()
// Detail panel should open — look for "Open full details" link
await expect(page.getByText('Open full details')).toBeVisible()
// Overview section
await expect(page.getByText('Correlation').first()).toBeVisible()
})
test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => {
await page.goto('/apps/order-service')
// Breadcrumb shows app scope
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
// Table should still render
await expect(page.getByText('Recent Exchanges')).toBeVisible()
})
test('sidebar navigation works', async ({ page }) => {
await page.goto('/apps')
// Click on an app in the sidebar
const sidebarApp = page.getByText('order-service').first()
await sidebarApp.click()
// URL should change to the app scope
await expect(page).toHaveURL(/\/apps\/order-service/)
})
test('inspect button navigates to exchange detail', async ({ page }) => {
await page.goto('/apps')
await widenTimeRange(page)
// Wait for table rows to appear
const firstRow = page.locator('table tbody tr').first()
await expect(firstRow).toBeVisible()
// Click the inspect button (↗) on first row
const inspectBtn = firstRow.locator('button[title="Inspect exchange"]')
await inspectBtn.click()
// Should navigate to exchange detail page
await expect(page).toHaveURL(/\/exchanges\//)
})
})

60
e2e/exchanges.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test'
test.describe('Exchange Detail (/exchanges/:id)', () => {
test('renders exchange header, timeline, and message panels', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Exchange header — use the one NOT in the breadcrumb
await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible()
// Header stats
await expect(page.getByText('Duration').first()).toBeVisible()
await expect(page.getByText('Processors').first()).toBeVisible()
// Processor Timeline section
await expect(page.getByText('Processor Timeline').first()).toBeVisible()
// Timeline/Flow toggle buttons
await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible()
// Message IN panel
await expect(page.getByText('Message IN')).toBeVisible()
await expect(page.getByText('Headers').first()).toBeVisible()
await expect(page.getByText('Body').first()).toBeVisible()
})
test('switching between Timeline and Flow view', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Default view is timeline (gantt)
const timelineBtn = page.getByRole('button', { name: 'Timeline' })
const flowBtn = page.getByRole('button', { name: 'Flow' })
// Switch to Flow view
await flowBtn.click()
// Flow view should render (RouteFlow component)
await expect(flowBtn).toHaveClass(/active|Active/)
// Switch back to Timeline
await timelineBtn.click()
await expect(timelineBtn).toHaveClass(/active|Active/)
})
test('not-found exchange shows warning', async ({ page }) => {
await page.goto('/exchanges/nonexistent-id')
await expect(page.getByText('not found', { exact: false })).toBeVisible()
})
test('breadcrumb navigation works', async ({ page }) => {
await page.goto('/exchanges/E-2026-03-18-00201')
// Click Applications breadcrumb to go back
const appsBreadcrumb = page.getByRole('link', { name: 'Applications' })
await appsBreadcrumb.click()
await expect(page).toHaveURL(/\/apps/)
})
})

63
e2e/routes.spec.ts Normal file
View File

@@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test'
test.describe('Routes (/routes)', () => {
test('renders KPI cards, route table, and charts', async ({ page }) => {
await page.goto('/routes')
// KPI cards
await expect(page.getByText('Total Throughput')).toBeVisible()
await expect(page.getByText('System Error Rate')).toBeVisible()
await expect(page.getByText('Latency Percentiles')).toBeVisible()
await expect(page.getByText('Active Routes')).toBeVisible()
await expect(page.getByText('In-Flight Exchanges')).toBeVisible()
// Route performance table
await expect(page.getByText('Per-Route Performance')).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible()
const rows = page.locator('table tbody tr')
expect(await rows.count()).toBeGreaterThan(0)
// Charts render
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
await expect(page.getByText('Latency (ms)')).toBeVisible()
await expect(page.getByText('Errors by Route')).toBeVisible()
await expect(page.getByText('Message Volume (msg/min)')).toBeVisible()
// Auto-refresh indicator
await expect(page.getByText('Auto-refresh: 30s')).toBeVisible()
})
test('clicking a route row navigates to route detail', async ({ page }) => {
await page.goto('/routes')
// Click first route row
const firstRow = page.locator('table tbody tr').first()
await firstRow.click()
// Should navigate to route detail
await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/)
// Route detail view: processor performance table
await expect(page.getByText('Processor Performance')).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible()
// Route Flow diagram
await expect(page.getByText('Route Flow')).toBeVisible()
})
test('app-scoped routes view filters data', async ({ page }) => {
await page.goto('/routes/order-service')
// Breadcrumb shows scope
await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible()
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
// Table still renders
await expect(page.getByText('Per-Route Performance')).toBeVisible()
})
})

814
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@cameleer/design-system",
"version": "0.1.2",
"version": "0.1.42",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
@@ -10,10 +10,12 @@
"types": "./dist/index.es.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
"./style.css": "./dist/style.css",
"./assets/*": "./assets/*"
},
"files": [
"dist"
"dist",
"assets"
],
"sideEffects": [
"*.css"
@@ -35,6 +37,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
@@ -52,6 +55,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3.2.4",
"happy-dom": "^20.8.4",
"typescript": "^5.6.0",
"vite": "^6.0.0",

21
playwright.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: 0,
use: {
baseURL: 'http://localhost:5173',
headless: true,
viewport: { width: 1440, height: 900 },
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
timeout: 15_000,
},
})

View File

@@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData'
import { exchanges } from './mocks/exchanges'
import { routes } from './mocks/routes'
import { agents } from './mocks/agents'
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
import { buildRouteToAppMap } from './mocks/sidebar'
import { LayoutShell } from './layout/LayoutShell'
const routeToApp = buildRouteToAppMap()
@@ -78,6 +79,7 @@ export default function App() {
return (
<>
<Routes>
<Route element={<LayoutShell />}>
<Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
@@ -93,6 +95,7 @@ export default function App() {
<Route path="/admin/oidc" element={<OidcConfig />} />
<Route path="/admin/rbac" element={<UserManagement />} />
<Route path="/api-docs" element={<ApiDocs />} />
</Route>
<Route path="/inventory" element={<Inventory />} />
</Routes>
<CommandPalette

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
it('renders danger variant icon', () => {
render(<AlertDialog {...defaultProps} variant="danger" />)
// Icon area should be present (aria-hidden)
expect(screen.getByText('✕')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders warning variant icon', () => {
render(<AlertDialog {...defaultProps} variant="warning" />)
expect(screen.getByText('⚠')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders info variant icon', () => {
render(<AlertDialog {...defaultProps} variant="info" />)
expect(screen.getByText('')).toBeInTheDocument()
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import React, { useEffect, useRef } from 'react'
import { XCircle, AlertTriangle, Info } from 'lucide-react'
import { Modal } from '../Modal/Modal'
import { Button } from '../../primitives/Button/Button'
import styles from './AlertDialog.module.css'
@@ -16,10 +17,10 @@ interface AlertDialogProps {
className?: string
}
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
danger: '✕',
warning: '⚠',
info: '',
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
danger: <XCircle size={20} />,
warning: <AlertTriangle size={20} />,
info: <Info size={20} />,
}
export function AlertDialog({

View File

@@ -72,7 +72,7 @@
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 6px 10px;
font-size: 11px;
font-size: 12px;
pointer-events: none;
z-index: 10;
white-space: nowrap;
@@ -118,7 +118,7 @@
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
}
@@ -130,12 +130,12 @@
}
.legendLabel {
font-size: 11px;
font-size: 12px;
}
/* Axis labels */
.yLabel {
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
writing-mode: vertical-lr;
transform: rotate(180deg);
@@ -151,7 +151,7 @@
.xLabel {
text-align: center;
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
padding-left: 48px;
}

View File

@@ -32,7 +32,7 @@
background-color: var(--bg-inset);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
margin-left: -8px;

View File

@@ -53,7 +53,7 @@
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 6px 10px;
font-size: 11px;
font-size: 12px;
pointer-events: none;
z-index: 10;
white-space: nowrap;
@@ -63,7 +63,7 @@
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
font-size: 11px;
font-size: 12px;
}
.tooltipRow {
@@ -105,7 +105,7 @@
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
}
@@ -117,11 +117,11 @@
}
.legendLabel {
font-size: 11px;
font-size: 12px;
}
.yLabel {
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
writing-mode: vertical-lr;
transform: rotate(180deg);
@@ -137,7 +137,7 @@
.xLabel {
text-align: center;
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
padding-left: 48px;
}

View File

@@ -83,6 +83,20 @@ export function BarChart({
setTooltip({ x: mx, y: my, label: catLabel, values })
}
function showBarTooltip(e: React.MouseEvent<SVGRectElement>, cat: string) {
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
handleMouseEnter(
cat,
e.clientX - rect.left,
e.clientY - rect.top,
series.map((ss, ssi) => ({
series: ss.label,
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
})),
)
}
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
@@ -138,19 +152,7 @@ export function BarChart({
height={barH}
fill={color}
className={styles.bar}
onMouseEnter={(e) => {
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
handleMouseEnter(
cat,
e.clientX - rect.left,
e.clientY - rect.top,
series.map((ss, ssi) => ({
series: ss.label,
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
})),
)
}}
onMouseEnter={(e) => showBarTooltip(e, cat)}
/>
)
})}
@@ -184,19 +186,7 @@ export function BarChart({
height={barH}
fill={color}
className={styles.bar}
onMouseEnter={(e) => {
const svgEl = e.currentTarget.closest('svg')!.getBoundingClientRect()
handleMouseEnter(
cat,
e.clientX - svgEl.left,
e.clientY - svgEl.top,
series.map((ss, ssi) => ({
series: ss.label,
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
})),
)
}}
onMouseEnter={(e) => showBarTooltip(e, cat)}
/>
)
})}

View File

@@ -16,7 +16,7 @@
.sep {
color: var(--text-faint);
font-size: 11px;
font-size: 12px;
}
.link {

View File

@@ -63,7 +63,7 @@
border: 1px solid var(--amber-light);
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
font-size: 12px;
flex-shrink: 0;
}
@@ -133,7 +133,7 @@
background: var(--bg-inset);
border-radius: 10px;
padding: 0 6px;
font-size: 10px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-muted);
}
@@ -227,7 +227,7 @@
display: inline-flex;
padding: 1px 7px;
border-radius: 10px;
font-size: 10px;
font-size: 12px;
font-family: var(--font-mono);
font-weight: 600;
background: var(--bg-inset);
@@ -239,13 +239,13 @@
.itemTime {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
color: var(--text-faint);
margin-left: auto;
}
.itemMeta {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
margin-top: 2px;
@@ -259,7 +259,7 @@
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 10px;
font-size: 12px;
padding: 2px 6px;
border-radius: var(--radius-sm);
flex-shrink: 0;
@@ -277,6 +277,23 @@
overflow-y: auto;
}
/* Match context snippet */
.matchContext {
font-size: 12px;
color: var(--text-faint);
font-family: var(--font-mono);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchContext em {
font-style: normal;
color: var(--amber);
font-weight: 600;
}
/* Match highlight */
.mark {
background: none;
@@ -299,6 +316,6 @@
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
import styles from './CommandPalette.module.css'
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
@@ -12,24 +13,36 @@ interface CommandPaletteProps {
onSelect: (result: SearchResult) => void
data: SearchResult[]
onOpen?: () => void
onQueryChange?: (query: string) => void
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
* Useful for applying the query as a full-text search filter. */
onSubmit?: (query: string) => void
}
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
all: 'All',
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
application: 'Applications',
exchange: 'Exchanges',
attribute: 'Attributes',
route: 'Routes',
agent: 'Agents',
}
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all',
/** Preferred display order for known categories */
const KNOWN_CATEGORY_ORDER: string[] = [
'application',
'exchange',
'attribute',
'route',
'agent',
]
function categoryLabel(cat: string): string {
if (cat === 'all') return 'All'
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
if (!query && (!matchRanges || matchRanges.length === 0)) return text
@@ -60,12 +73,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
return <>{parts}</>
}
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
const [activeCategory, setActiveCategory] = useState<string>('all')
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
const [focusedIdx, setFocusedIdx] = useState(0)
const [expandedId, setExpandedId] = useState<string | null>(null)
const userNavigated = useRef(false)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
@@ -88,25 +102,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
setQuery('')
setFocusedIdx(0)
setExpandedId(null)
userNavigated.current = false
}
}, [open])
// Filter results
const filtered = useMemo(() => {
// Stage 1: apply text query + scope filters (used for counts)
const queryFiltered = useMemo(() => {
let results = data
if (activeCategory !== 'all') {
results = results.filter((r) => r.category === activeCategory)
}
if (query.trim()) {
const q = query.toLowerCase()
results = results.filter(
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
)
}
// Apply scope filters
for (const sf of scopeFilters) {
results = results.filter((r) =>
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
@@ -114,11 +124,17 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
}
return results
}, [data, query, activeCategory, scopeFilters])
}, [data, query, scopeFilters])
// Stage 2: apply category filter (used for display)
const filtered = useMemo(() => {
if (activeCategory === 'all') return queryFiltered
return queryFiltered.filter((r) => r.category === activeCategory)
}, [queryFiltered, activeCategory])
// Group results by category
const grouped = useMemo(() => {
const map = new Map<SearchCategory, SearchResult[]>()
const map = new Map<string, SearchResult[]>()
for (const r of filtered) {
if (!map.has(r.category)) map.set(r.category, [])
map.get(r.category)!.push(r)
@@ -129,13 +145,26 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
// Flatten for keyboard nav
const flatResults = useMemo(() => filtered, [filtered])
// Counts per category
// Counts per category (from query-filtered, before category filter)
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = { all: data.length }
for (const r of data) {
const counts: Record<string, number> = { all: queryFiltered.length }
for (const r of queryFiltered) {
counts[r.category] = (counts[r.category] ?? 0) + 1
}
return counts
}, [queryFiltered])
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
const visibleCategories = useMemo(() => {
const dataCategories = new Set(data.map((r) => r.category))
const tabs: string[] = ['all']
for (const cat of KNOWN_CATEGORY_ORDER) {
if (dataCategories.has(cat)) tabs.push(cat)
}
for (const cat of dataCategories) {
if (!tabs.includes(cat)) tabs.push(cat)
}
return tabs
}, [data])
function handleKeyDown(e: React.KeyboardEvent) {
@@ -145,15 +174,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
break
case 'ArrowDown':
e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
break
case 'ArrowUp':
e.preventDefault()
userNavigated.current = true
setFocusedIdx((i) => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
if (flatResults[focusedIdx]) {
if (!userNavigated.current && onSubmit && query.trim()) {
onSubmit(query.trim())
onClose()
} else if (flatResults[focusedIdx]) {
onSelect(flatResults[focusedIdx])
onClose()
}
@@ -171,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
}
function toggleExpanded(e: React.MouseEvent, id: string) {
e.stopPropagation()
setExpandedId((prev) => (prev === id ? null : id))
}
if (!open) return null
return createPortal(
<div className={styles.overlay} onClick={onClose} data-testid="command-palette-overlay">
<div
className={styles.overlay}
onClick={onClose}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClose() }}
role="button"
tabIndex={0}
aria-label="Close command palette"
data-testid="command-palette-overlay"
>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
@@ -185,7 +232,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
>
{/* Search input area */}
<div className={styles.searchArea}>
<span className={styles.searchIcon} aria-hidden="true"></span>
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
{scopeFilters.map((sf, i) => (
<span key={i} className={styles.scopeTag}>
<span className={styles.scopeField}>{sf.field}:</span>
@@ -195,7 +242,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onClick={() => removeScopeFilter(i)}
aria-label={`Remove filter ${sf.field}:${sf.value}`}
>
×
<X size={10} />
</button>
</span>
))}
@@ -208,6 +255,8 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onChange={(e) => {
setQuery(e.target.value)
setFocusedIdx(0)
userNavigated.current = false
onQueryChange?.(e.target.value)
}}
aria-label="Search"
/>
@@ -216,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
{/* Category tabs */}
<div className={styles.tabs} role="tablist">
{ALL_CATEGORIES.map((cat) => (
{visibleCategories.map((cat) => (
<button
key={cat}
role="tab"
@@ -232,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
setFocusedIdx(0)
}}
>
{CATEGORY_LABELS[cat]}
{categoryLabel(cat)}
{categoryCounts[cat] != null && (
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
)}
@@ -253,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
Array.from(grouped.entries()).map(([category, items]) => (
<div key={category} className={styles.group}>
<div className={styles.groupHeader}>
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
<SectionHeader>{categoryLabel(category)}</SectionHeader>
</div>
{items.map((result) => {
const flatIdx = flatResults.indexOf(result)
@@ -276,7 +325,13 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
onSelect(result)
onClose()
}}
onMouseEnter={() => setFocusedIdx(flatIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(result)
onClose()
}
}}
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
>
<div className={styles.itemMain}>
{result.icon && (
@@ -301,18 +356,21 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
<div className={styles.itemMeta}>
{highlightText(result.meta, query)}
</div>
{result.matchContext && (
<div
className={styles.matchContext}
dangerouslySetInnerHTML={{ __html: result.matchContext }}
/>
)}
</div>
{result.expandedContent && (
<button
className={styles.expandBtn}
onClick={(e) => {
e.stopPropagation()
setExpandedId((prev) => (prev === result.id ? null : result.id))
}}
onClick={(e) => toggleExpanded(e, result.id)}
aria-expanded={isExpanded}
aria-label="Toggle detail"
>
{isExpanded ? '▲' : '▼'}
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)}
</div>
@@ -341,7 +399,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
</div>
<div className={styles.shortcut}>
<KeyboardHint keys="Enter" />
<span>Open</span>
<span>Search</span>
</div>
<div className={styles.shortcut}>
<KeyboardHint keys="Esc" />

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
/** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
export type SearchCategory = string
export interface SearchResult {
id: string
@@ -13,6 +14,10 @@ export interface SearchResult {
path?: string
expandedContent?: string
matchRanges?: [number, number][]
/** Skip client-side query filtering (result already matched server-side) */
serverFiltered?: boolean
/** Server-side match snippet with <em> tags around matched text */
matchContext?: string
}
export interface ScopeFilter {

View File

@@ -12,6 +12,23 @@
box-shadow: none;
}
.fillHeight {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.fillHeight .scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.fillHeight .footer {
flex-shrink: 0;
}
.scroll {
overflow-x: auto;
}
@@ -25,7 +42,7 @@
.th {
padding: 9px 14px;
text-align: left;
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
@@ -35,6 +52,9 @@
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
transition: color 0.12s;
position: sticky;
top: 0;
z-index: 1;
}
.th.sortable {
@@ -127,7 +147,7 @@
.rangeInfo {
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
}
.paginationRight {
@@ -137,7 +157,7 @@
}
.pageSizeLabel {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
}
@@ -153,7 +173,7 @@
.pageNum {
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
min-width: 40px;
text-align: center;

View File

@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
rowAccent,
expandedContent,
flush = false,
fillHeight = false,
onSortChange,
}: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null)
@@ -81,7 +82,7 @@ export function DataTable<T extends { id: string }>({
}))
return (
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
<div className={styles.scroll}>
<table className={styles.table}>
<thead>

View File

@@ -20,6 +20,10 @@ export interface DataTableProps<T extends { id: string }> {
expandedContent?: (row: T) => ReactNode | null
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
flush?: boolean
/** Make the table fill remaining vertical space in a flex parent.
* The table body scrolls while the header stays sticky and the
* pagination footer stays pinned at the bottom. */
fillHeight?: boolean
/** Controlled sort: called when the user clicks a sortable column header.
* When provided, the component skips client-side sorting — the caller is
* responsible for providing `data` in the desired order. */

View File

@@ -1,4 +1,21 @@
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.15);
z-index: 99;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 0;
overflow: hidden;
transition: width 0.25s ease, opacity 0.2s ease;
@@ -7,13 +24,15 @@
display: flex;
flex-direction: column;
background: var(--bg-surface);
flex-shrink: 0;
z-index: 100;
box-shadow: none;
}
.panel.open {
width: 400px;
opacity: 1;
border-left-color: var(--border);
box-shadow: var(--shadow-lg);
animation: slideInRight 0.25s ease-out both;
}

View File

@@ -1,4 +1,5 @@
import { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import styles from './DetailPanel.module.css'
interface Tab {
@@ -22,7 +23,9 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
return (
const content = (
<>
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
<aside
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
aria-hidden={!open}
@@ -64,5 +67,10 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
</div>
)}
</aside>
</>
)
// Portal to AppShell level if target exists, otherwise render in place
const portalTarget = document.getElementById('cameleer-detail-panel-root')
return portalTarget ? createPortal(content, portalTarget) : content
}

View File

@@ -29,7 +29,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-surface);
font-size: 11px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
outline: none;
@@ -81,7 +81,7 @@
background: none;
border: none;
color: var(--text-muted);
font-size: 11px;
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
@@ -165,7 +165,7 @@
}
.time {
font-size: 10px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-muted);
margin-top: 1px;
@@ -215,7 +215,7 @@
border: 1px solid var(--amber-light);
border-radius: 0;
color: var(--amber-deep);
font-size: 11px;
font-size: 12px;
font-family: var(--font-mono);
cursor: pointer;
text-align: center;

View File

@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
import styles from './EventFeed.module.css'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
return ''
}
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
error: '\u2715', // ✕
warning: '\u26A0', // ⚠
success: '\u25B6', // ▶
running: '\u2699', // ⚙
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
error: <XIcon size={14} />,
warning: <AlertTriangle size={14} />,
success: <Play size={14} />,
running: <Loader size={14} />,
}
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
// Auto-scroll to bottom
const scrollToBottom = useCallback(() => {
// Auto-scroll to top (newest entries are at top in desc sort)
const scrollToTop = useCallback(() => {
const el = scrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
el.scrollTop = 0
}
}, [])
useEffect(() => {
if (!isPaused) {
scrollToBottom()
scrollToTop()
}
}, [events, isPaused, scrollToBottom])
}, [events, isPaused, scrollToTop])
function handleScroll() {
const el = scrollRef.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
setIsPaused(!atBottom)
const atTop = el.scrollTop < 8
setIsPaused(!atTop)
}
function toggleFilter(severity: SeverityFilter) {
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
onClick={() => setSearch('')}
aria-label="Clear search"
>
×
<XIcon size={12} />
</button>
)}
</div>
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
className={styles.resumeBtn}
onClick={() => {
setIsPaused(false)
scrollToBottom()
scrollToTop()
}}
>
Resume auto-scroll
&uarr; Scroll to latest
</button>
)}
</div>

View File

@@ -43,7 +43,7 @@
}
.clearAll {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
padding: 3px 6px;

View File

@@ -1,4 +1,5 @@
import { useState, type ChangeEvent } from 'react'
import { Search } from 'lucide-react'
import styles from './FilterBar.module.css'
import { Input } from '../../primitives/Input/Input'
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
@@ -77,12 +78,7 @@ export function FilterBar({
if (onSearchChange) onSearchChange('')
else setInternalSearch('')
} : undefined}
icon={
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
}
icon={<Search size={13} />}
/>
</div>

View File

@@ -63,7 +63,7 @@
gap: 16px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
background: var(--bg-surface);
}

View File

@@ -30,7 +30,7 @@
}
.label {
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
@@ -55,7 +55,7 @@
.trend {
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 2px;
@@ -68,7 +68,7 @@
.trendMuted { color: var(--text-muted); }
.subtitle {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}

View File

@@ -1,11 +1,11 @@
import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties } from 'react'
import type { CSSProperties, ReactNode } from 'react'
export interface KpiItem {
label: string
value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string

View File

@@ -63,7 +63,7 @@
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 6px 10px;
font-size: 11px;
font-size: 12px;
pointer-events: none;
z-index: 10;
white-space: nowrap;
@@ -108,7 +108,7 @@
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
}
@@ -120,11 +120,11 @@
}
.legendLabel {
font-size: 11px;
font-size: 12px;
}
.yLabel {
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
writing-mode: vertical-lr;
transform: rotate(180deg);
@@ -140,7 +140,7 @@
.xLabel {
text-align: center;
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
padding-left: 48px;
}

View File

@@ -1,7 +1,5 @@
.container {
overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0;
font-family: var(--font-mono);
}
@@ -20,7 +18,7 @@
.timestamp {
flex-shrink: 0;
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
min-width: 56px;
}
@@ -58,6 +56,11 @@
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
.levelTrace {
color: var(--text-faint);
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
}
.message {
font-size: 12px;
font-family: var(--font-mono);

View File

@@ -7,6 +7,7 @@ const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
]
describe('LogViewer', () => {
@@ -16,14 +17,16 @@ describe('LogViewer', () => {
expect(screen.getByText('High memory usage')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument()
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
})
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument()
expect(screen.getByText('TRACE')).toBeInTheDocument()
})
it('renders with custom maxHeight (number)', () => {

View File

@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
message: string
}
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
trace: styles.levelTrace,
}
function formatTime(iso: string): string {
@@ -35,18 +36,18 @@ function formatTime(iso: string): string {
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const isAtTopRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
isAtTopRef.current = el.scrollTop < 20
}, [])
useEffect(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) {
el.scrollTop = el.scrollHeight
if (el && isAtTopRef.current) {
el.scrollTop = 0
}
}, [entries])

View File

@@ -51,7 +51,7 @@
.dividerText {
color: var(--text-muted);
font-size: 11px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
@@ -71,7 +71,7 @@
}
.forgotLink {
font-size: 11px;
font-size: 12px;
color: var(--amber);
font-weight: 500;
background: none;

View File

@@ -45,7 +45,7 @@
}
.meta {
font-size: 11px;
font-size: 12px;
color: var(--sidebar-muted);
font-family: var(--font-mono);
white-space: nowrap;
@@ -59,7 +59,7 @@
.count {
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
color: var(--sidebar-muted);
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;

View File

@@ -46,7 +46,7 @@
.chevron {
color: var(--text-faint);
font-size: 11px;
font-size: 12px;
flex-shrink: 0;
}

View File

@@ -8,7 +8,7 @@
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-size: 12px;
}
.row.clickable {
@@ -32,7 +32,7 @@
text-align: right;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -83,7 +83,7 @@
.dur {
width: 42px;
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
text-align: right;
@@ -96,6 +96,58 @@
padding: 2px 0 2px 4px;
}
/* Action trigger — hidden by default, shown on hover/selected */
.actionsTrigger {
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
display: flex;
align-items: center;
}
.row:hover .actionsTrigger,
.actionsVisible {
opacity: 1;
}
.actionsBtn {
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
line-height: 1;
color: var(--text-muted);
transition: all 0.1s;
font-family: var(--font-body);
}
.actionsBtn:hover {
background: var(--bg-hover);
border-color: var(--border-subtle);
color: var(--text-primary);
}
/* Badges */
.badge {
font-family: var(--font-mono);
font-size: 7px;
font-weight: 600;
color: #fff;
border-radius: 6px;
padding: 0 4px;
text-transform: uppercase;
white-space: nowrap;
margin-left: 4px;
vertical-align: middle;
}
.badgeInfo { background: var(--running); }
.badgeSuccess { background: var(--success); }
.badgeWarning { background: var(--amber); }
.badgeError { background: var(--error); }
.empty {
color: var(--text-muted);
font-size: 12px;

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ProcessorTimeline } from './ProcessorTimeline'
const processors = [
{ name: 'Validate', type: 'validator', durationMs: 12, status: 'ok' as const, startMs: 0 },
{ name: 'Enrich', type: 'enricher', durationMs: 35, status: 'slow' as const, startMs: 12 },
{ name: 'Route', type: 'router', durationMs: 8, status: 'fail' as const, startMs: 47 },
]
describe('ProcessorTimeline', () => {
it('renders processor names', () => {
render(<ProcessorTimeline processors={processors} totalMs={55} />)
expect(screen.getByText('Validate')).toBeInTheDocument()
expect(screen.getByText('Enrich')).toBeInTheDocument()
expect(screen.getByText('Route')).toBeInTheDocument()
})
it('does not render action trigger when no actions provided', () => {
const { container } = render(
<ProcessorTimeline processors={processors} totalMs={55} />,
)
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
})
it('renders action trigger when actions provided', () => {
const { container } = render(
<ProcessorTimeline
processors={processors}
totalMs={55}
actions={[{ label: 'Change Log Level', onClick: () => {} }]}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(3)
})
it('clicking action trigger does not fire onProcessorClick', async () => {
const onProcessorClick = vi.fn()
const user = userEvent.setup()
const { container } = render(
<ProcessorTimeline
processors={processors}
totalMs={55}
onProcessorClick={onProcessorClick}
actions={[{ label: 'Test Action', onClick: () => {} }]}
/>,
)
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
await user.click(trigger)
expect(onProcessorClick).not.toHaveBeenCalled()
})
it('calls action onClick when menu item clicked', async () => {
const actionClick = vi.fn()
const user = userEvent.setup()
const { container } = render(
<ProcessorTimeline
processors={processors}
totalMs={55}
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
/>,
)
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
await user.click(trigger)
await user.click(screen.getByText('Change Log Level'))
expect(actionClick).toHaveBeenCalledOnce()
})
it('supports dynamic getActions per processor', () => {
const { container } = render(
<ProcessorTimeline
processors={processors}
totalMs={55}
getActions={(proc) =>
proc.status === 'fail'
? [{ label: 'View Error', onClick: () => {} }]
: []
}
/>,
)
// Only the failing processor should have an action trigger
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(1)
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for Route')
})
})

View File

@@ -1,4 +1,8 @@
import type { ReactNode } from 'react'
import { EllipsisVertical } from 'lucide-react'
import styles from './ProcessorTimeline.module.css'
import { Dropdown } from '../Dropdown/Dropdown'
import type { NodeBadge } from '../RouteFlow/RouteFlow'
export interface ProcessorStep {
name: string
@@ -6,6 +10,15 @@ export interface ProcessorStep {
durationMs: number
status: 'ok' | 'slow' | 'fail'
startMs: number
badges?: NodeBadge[]
}
export interface ProcessorAction {
label: string
icon?: ReactNode
onClick: () => void
disabled?: boolean
divider?: boolean
}
interface ProcessorTimelineProps {
@@ -13,6 +26,8 @@ interface ProcessorTimelineProps {
totalMs: number
onProcessorClick?: (processor: ProcessorStep, index: number) => void
selectedIndex?: number
actions?: ProcessorAction[]
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
className?: string
}
@@ -26,6 +41,8 @@ export function ProcessorTimeline({
totalMs,
onProcessorClick,
selectedIndex,
actions,
getActions,
className,
}: ProcessorTimelineProps) {
const safeTotal = totalMs || 1
@@ -70,6 +87,16 @@ export function ProcessorTimeline({
>
<div className={styles.name} title={proc.name}>
{proc.name}
{proc.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</div>
<div className={styles.barBg}>
<div
@@ -82,6 +109,30 @@ export function ProcessorTimeline({
</div>
</div>
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
{(() => {
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
if (resolvedActions.length === 0) return null
return (
<div
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Dropdown
trigger={
<button
className={styles.actionsBtn}
aria-label={`Actions for ${proc.name}`}
type="button"
>
<EllipsisVertical size={14} />
</button>
}
items={resolvedActions}
/>
</div>
)
})()}
</div>
)
})}

View File

@@ -52,7 +52,7 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-size: 12px;
flex-shrink: 0;
}
@@ -97,7 +97,7 @@
.label {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
@@ -112,7 +112,7 @@
.duration {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
}
@@ -188,17 +188,100 @@
outline-offset: 2px;
}
/* Bottleneck badge */
.bottleneckBadge {
/* Action trigger — hidden by default, shown on hover/selected */
.actionsTrigger {
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: 4px;
}
.node:hover .actionsTrigger,
.actionsVisible {
opacity: 1;
}
.actionsBtn {
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
line-height: 1;
color: var(--text-muted);
transition: all 0.1s;
font-family: var(--font-body);
}
.actionsBtn:hover {
background: var(--bg-hover);
border-color: var(--border-subtle);
color: var(--text-primary);
}
/* Badges */
.badgeRow {
position: absolute;
top: -7px;
right: 8px;
display: flex;
gap: 3px;
pointer-events: auto;
}
.badge {
font-family: var(--font-mono);
font-size: 8px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
background: var(--error);
color: #fff;
border-radius: 8px;
padding: 1px 6px;
text-transform: uppercase;
white-space: nowrap;
letter-spacing: 0.3px;
}
.badgeInfo { background: var(--running); }
.badgeSuccess { background: var(--success); }
.badgeWarning { background: var(--amber); }
.badgeError { background: var(--error); }
/* Node wrapper (replaces inline style) */
.nodeWrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
/* Multi-flow sections */
.flowSection {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.flowSectionSeparated {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border);
}
.flowLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
padding-left: 2px;
width: 100%;
}
.flowLabelDefault { color: var(--text-muted); }
.flowLabelError { color: var(--error); }
.flowLabelWarning { color: var(--warning); }
.flowLabelInfo { color: var(--running); }

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RouteFlow } from './RouteFlow'
const nodes = [
{ name: 'jms:orders', type: 'from' as const, durationMs: 4, status: 'ok' as const },
{ name: 'OrderValidator', type: 'process' as const, durationMs: 8, status: 'ok' as const },
{ name: 'http:payment-api', type: 'to' as const, durationMs: 187, status: 'slow' as const },
{ name: 'dead-letter:failed', type: 'error-handler' as const, durationMs: 14, status: 'fail' as const },
]
describe('RouteFlow', () => {
it('renders node names', () => {
render(<RouteFlow nodes={nodes} />)
expect(screen.getByText('jms:orders')).toBeInTheDocument()
expect(screen.getByText('OrderValidator')).toBeInTheDocument()
expect(screen.getByText('http:payment-api')).toBeInTheDocument()
expect(screen.getByText('dead-letter:failed')).toBeInTheDocument()
})
it('does not render action trigger when no actions provided', () => {
const { container } = render(<RouteFlow nodes={nodes} />)
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
})
it('renders action trigger on all nodes including error handlers when actions provided', () => {
const { container } = render(
<RouteFlow
nodes={nodes}
actions={[{ label: 'View Config', onClick: () => {} }]}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(4) // 3 main + 1 error handler
})
it('clicking action trigger does not fire onNodeClick', async () => {
const onNodeClick = vi.fn()
const user = userEvent.setup()
const { container } = render(
<RouteFlow
nodes={nodes}
onNodeClick={onNodeClick}
actions={[{ label: 'Test Action', onClick: () => {} }]}
/>,
)
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
await user.click(trigger)
expect(onNodeClick).not.toHaveBeenCalled()
})
it('calls action onClick when menu item clicked', async () => {
const actionClick = vi.fn()
const user = userEvent.setup()
const { container } = render(
<RouteFlow
nodes={nodes}
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
/>,
)
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
await user.click(trigger)
await user.click(screen.getByText('Change Log Level'))
expect(actionClick).toHaveBeenCalledOnce()
})
it('supports dynamic getActions per node', () => {
const { container } = render(
<RouteFlow
nodes={nodes}
getActions={(node) =>
node.type === 'process'
? [{ label: 'Edit Processor', onClick: () => {} }]
: []
}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(1)
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
})
})
const multiFlows = [
{
label: 'Main Route',
nodes: [
{ name: 'timer:tick', type: 'from' as const, durationMs: 0, status: 'ok' as const },
{ name: 'Processor1', type: 'process' as const, durationMs: 8, status: 'ok' as const },
],
},
{
label: 'onException',
variant: 'error' as const,
nodes: [
{ name: 'LogHandler', type: 'process' as const, durationMs: 3, status: 'ok' as const },
{ name: 'dead-letter:errors', type: 'to' as const, durationMs: 8, status: 'fail' as const },
],
},
]
describe('RouteFlow (multi-flow)', () => {
it('renders all segment labels', () => {
render(<RouteFlow flows={multiFlows} />)
expect(screen.getByText('Main Route')).toBeInTheDocument()
expect(screen.getByText('onException')).toBeInTheDocument()
})
it('renders all nodes across segments', () => {
render(<RouteFlow flows={multiFlows} />)
expect(screen.getByText('timer:tick')).toBeInTheDocument()
expect(screen.getByText('Processor1')).toBeInTheDocument()
expect(screen.getByText('LogHandler')).toBeInTheDocument()
expect(screen.getByText('dead-letter:errors')).toBeInTheDocument()
})
it('uses global flat indexing for onNodeClick', async () => {
const onNodeClick = vi.fn()
const user = userEvent.setup()
render(<RouteFlow flows={multiFlows} onNodeClick={onNodeClick} />)
// Click the first node of the second flow (global index = 2)
await user.click(screen.getByText('LogHandler'))
expect(onNodeClick).toHaveBeenCalledWith(
expect.objectContaining({ name: 'LogHandler' }),
2,
)
})
it('selectedIndex highlights correct node across flows', () => {
const { container } = render(<RouteFlow flows={multiFlows} selectedIndex={3} />)
// Index 3 = dead-letter:errors (2nd node of 2nd flow)
const selectedNodes = container.querySelectorAll('[class*="nodeSelected"]')
expect(selectedNodes.length).toBe(1)
expect(selectedNodes[0]).toHaveTextContent('dead-letter:errors')
})
it('actions work in multi-flow mode', () => {
const { container } = render(
<RouteFlow
flows={multiFlows}
actions={[{ label: 'Test Action', onClick: () => {} }]}
/>,
)
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
expect(triggers.length).toBe(4)
})
it('flows takes precedence over nodes', () => {
render(
<RouteFlow
nodes={nodes}
flows={multiFlows}
/>,
)
// Should render flow content, not nodes content
expect(screen.getByText('Main Route')).toBeInTheDocument()
expect(screen.queryByText('jms:orders')).not.toBeInTheDocument()
})
})

View File

@@ -1,4 +1,14 @@
import type { ReactNode } from 'react'
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
import styles from './RouteFlow.module.css'
import { Dropdown } from '../Dropdown/Dropdown'
import { formatDuration } from '../../../utils/format-utils'
export interface NodeBadge {
label: string
variant?: 'info' | 'success' | 'warning' | 'error'
onClick?: () => void
}
export interface RouteNode {
name: string
@@ -6,21 +16,33 @@ export interface RouteNode {
durationMs: number
status: 'ok' | 'slow' | 'fail'
isBottleneck?: boolean
badges?: NodeBadge[]
}
export interface NodeAction {
label: string
icon?: ReactNode
onClick: () => void
disabled?: boolean
divider?: boolean
}
export interface FlowSegment {
label: string
nodes: RouteNode[]
variant?: 'default' | 'error' | 'warning' | 'info'
}
interface RouteFlowProps {
nodes: RouteNode[]
nodes?: RouteNode[]
flows?: FlowSegment[]
onNodeClick?: (node: RouteNode, index: number) => void
selectedIndex?: number
actions?: NodeAction[]
getActions?: (node: RouteNode, index: number) => NodeAction[]
className?: string
}
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 {
if (status === 'fail') return styles.durBreach
if (ms < 50) return styles.durFast
@@ -29,12 +51,12 @@ function durationClass(ms: number, status: string): string {
return styles.durBreach
}
const TYPE_ICONS: Record<string, string> = {
'from': '\u25B6',
'process': '\u2699',
'to': '\u25A2',
'choice': '\u25C6',
'error-handler': '\u26A0',
const TYPE_ICONS: Record<string, ReactNode> = {
'from': <Play size={14} />,
'process': <Cog size={14} />,
'to': <Square size={14} />,
'choice': <Diamond size={14} />,
'error-handler': <AlertTriangle size={14} />,
}
const ICON_CLASSES: Record<string, string> = {
@@ -52,12 +74,141 @@ function nodeStatusClass(node: RouteNode): string {
return styles.nodeHealthy
}
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
function renderActionTrigger(
node: RouteNode,
index: number,
isSelected: boolean,
actions?: NodeAction[],
getActions?: (node: RouteNode, index: number) => NodeAction[],
) {
const resolvedActions = getActions ? getActions(node, index) : (actions ?? [])
if (resolvedActions.length === 0) return null
return (
<div
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Dropdown
trigger={
<button
className={styles.actionsBtn}
aria-label={`Actions for ${node.name}`}
type="button"
>
<EllipsisVertical size={14} />
</button>
}
items={resolvedActions}
/>
</div>
)
}
const FLOW_LABEL_CLASSES: Record<string, string> = {
'default': styles.flowLabelDefault,
'error': styles.flowLabelError,
'warning': styles.flowLabelWarning,
'info': styles.flowLabelInfo,
}
function renderNodeChain(
nodes: RouteNode[],
globalIndexOffset: number,
onNodeClick?: RouteFlowProps['onNodeClick'],
selectedIndex?: number,
actions?: NodeAction[],
getActions?: (node: RouteNode, index: number) => NodeAction[],
) {
const isClickable = !!onNodeClick
return nodes.map((node, i) => {
const globalIndex = globalIndexOffset + i
const isSelected = selectedIndex === globalIndex
return (
<div key={i} className={styles.nodeWrapper}>
{i > 0 && (
<div className={styles.connector}>
<div className={styles.connectorLine} />
<div className={styles.connectorArrow} />
</div>
)}
<div
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
onClick={() => onNodeClick?.(node, globalIndex)}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onKeyDown={(e) => {
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onNodeClick?.(node, globalIndex)
}
}}
>
{(node.isBottleneck || node.badges?.length) ? (
<span className={styles.badgeRow}>
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
{node.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? <Square size={14} />}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
<div className={styles.label} title={node.name}>{node.name}</div>
</div>
<div className={styles.stats}>
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
</div>
</div>
)
})
}
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
// Multi-flow mode
if (flows && flows.length > 0) {
let globalOffset = 0
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{flows.map((flow, fi) => {
const sectionOffset = globalOffset
globalOffset += flow.nodes.length
const variant = flow.variant ?? 'default'
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
return (
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
</div>
)
})}
</div>
)
}
// Legacy mode (single nodes array with automatic error-handler separation)
const allNodes = nodes ?? []
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
// Map from mainNodes index back to original nodes index
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
if (n.type !== 'error-handler') acc.push(idx)
return acc
}, [])
@@ -70,7 +221,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
const isClickable = !!onNodeClick
return (
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div key={i} className={styles.nodeWrapper}>
{i > 0 && (
<div className={styles.connector}>
<div className={styles.connectorLine} />
@@ -89,9 +240,23 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
}
}}
>
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
{(node.isBottleneck || node.badges?.length) ? (
<span className={styles.badgeRow}>
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
{node.badges?.map((badge, bi) => (
<span
key={bi}
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
style={badge.onClick ? { cursor: 'pointer' } : undefined}
>
{badge.label}
</span>
))}
</span>
) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'}
{TYPE_ICONS[node.type] ?? <Square size={14} />}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
@@ -102,6 +267,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
</div>
</div>
)
@@ -110,7 +276,9 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
{errorHandlers.length > 0 && (
<div className={styles.errorSection}>
<div className={styles.errorLabel}>Error Handler</div>
{errorHandlers.map((node, i) => (
{errorHandlers.map((node, i) => {
const errOriginalIndex = allNodes.indexOf(node)
return (
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
{TYPE_ICONS['error-handler']}
@@ -124,8 +292,10 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
{formatDuration(node.durationMs)}
</div>
</div>
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
</div>
))}
)
})}
</div>
)}
</div>

View File

@@ -57,7 +57,7 @@
.count {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
background: var(--bg-hover);
color: var(--text-muted);

View File

@@ -17,7 +17,7 @@
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);

View File

@@ -38,7 +38,7 @@
.count {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
background: var(--bg-inset);
color: var(--text-muted);
padding: 1px 5px;

View File

@@ -89,7 +89,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for success variant', () => {
@@ -97,7 +97,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for warning variant', () => {
@@ -105,7 +105,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('shows correct icon for error variant', () => {
@@ -113,7 +113,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('dismisses toast when close button is clicked', () => {

View File

@@ -8,6 +8,7 @@ import {
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Toast.module.css'
// ── Types ──────────────────────────────────────────────────────────────────
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
const DEFAULT_DURATION = 5000
const EXIT_ANIMATION_MS = 300
const ICONS: Record<ToastVariant, string> = {
info: '',
success: '✓',
warning: '⚠',
error: '✕',
const ICONS: Record<ToastVariant, ReactNode> = {
info: <Info size={16} />,
success: <CheckCircle size={16} />,
warning: <AlertTriangle size={16} />,
error: <XCircle size={16} />,
}
// ── Context ────────────────────────────────────────────────────────────────
@@ -56,6 +57,10 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const dismiss = useCallback((id: string) => {
// Clear auto-dismiss timer if running
const timer = timersRef.current.get(id)
@@ -70,10 +75,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
)
// Remove after animation completes
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, EXIT_ANIMATION_MS)
}, [])
setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
}, [removeToast])
const toast = useCallback(
(options: ToastOptions): string => {
@@ -183,7 +186,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
aria-label="Dismiss notification"
type="button"
>
&times;
<X size={14} />
</button>
</div>
)

View File

@@ -59,7 +59,7 @@
}
.chevron {
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
line-height: 1;
}
@@ -85,7 +85,7 @@
.meta {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
color: var(--text-muted);
margin-left: auto;
padding-left: 8px;

View File

@@ -31,6 +31,52 @@ function flattenVisibleNodes(
return result
}
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
}
function handleArrowRight(
current: FlatNode | undefined,
currentIndex: number,
expandedSet: Set<string>,
visibleNodes: FlatNode[],
handleToggle: (id: string) => void,
focusNode: (id: string) => void,
) {
if (!current) return
const hasChildren = current.node.children && current.node.children.length > 0
if (!hasChildren) return
if (!expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
function handleArrowLeft(
current: FlatNode | undefined,
expandedSet: Set<string>,
handleToggle: (id: string) => void,
focusNode: (id: string) => void,
) {
if (!current) return
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else if (current.parentId !== null) {
focusNode(current.parentId)
}
}
interface TreeViewProps {
nodes: TreeNode[]
onSelect?: (id: string) => void
@@ -105,68 +151,13 @@ export function TreeView({
const current = visibleNodes[currentIndex]
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
break
}
case 'ArrowRight': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren) {
if (!expandedSet.has(current.node.id)) {
// Expand it
handleToggle(current.node.id)
} else {
// Move to first child (it will be the next visible node)
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
break
}
case 'ArrowLeft': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
// Collapse
handleToggle(current.node.id)
} else if (current.parentId !== null) {
// Move to parent
focusNode(current.parentId)
}
break
}
case 'Enter': {
e.preventDefault()
if (current) {
onSelect?.(current.node.id)
}
break
}
case 'Home': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[0].node.id)
}
break
}
case 'End': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
}
break
}
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
case 'Enter': { e.preventDefault(); if (current) onSelect?.(current.node.id); break }
case 'Home': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[0].node.id); break }
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -239,6 +230,10 @@ function TreeNodeRow({
return (
<li role="none">
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
onKeyDown={handleKeyDown} which handles Enter (select) and all arrow keys
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
fire the action twice. */}
<div
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}

View File

@@ -13,6 +13,7 @@ export type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel'
export { EntityList } from './EntityList/EntityList'
export { Dropdown } from './Dropdown/Dropdown'
export type { DropdownItem } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed'
export { GroupCard } from './GroupCard/GroupCard'
export { KpiStrip } from './KpiStrip/KpiStrip'
@@ -32,12 +33,16 @@ export { MultiSelect } from './MultiSelect/MultiSelect'
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
export { Popover } from './Popover/Popover'
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
export { RouteFlow } from './RouteFlow/RouteFlow'
export type { RouteNode } from './RouteFlow/RouteFlow'
export type { RouteNode, NodeAction, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { SplitPane } from './SplitPane/SplitPane'
export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView'
// Chart utilities for consumers using Recharts or custom charts
export { CHART_COLORS } from './_chart-utils'
export type { ChartSeries, DataPoint } from './_chart-utils'

View File

@@ -7,5 +7,8 @@ export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider'
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'
export * from './utils/rechartsTheme'

View File

@@ -2,6 +2,7 @@
display: flex;
height: 100vh;
overflow: hidden;
position: relative;
}
.main {

View File

@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
interface AppShellProps {
sidebar: ReactNode
children: ReactNode
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
detail?: ReactNode
}
export function AppShell({ sidebar, children, detail }: AppShellProps) {
export function AppShell({ sidebar, children }: AppShellProps) {
return (
<div className={styles.app}>
{sidebar}
<div className={styles.main}>
{children}
</div>
{detail}
<div id="cameleer-detail-panel-root" />
</div>
)
}

View File

@@ -5,6 +5,36 @@
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
transition: width 200ms ease;
}
.sidebarCollapsed {
width: 48px;
}
/* Collapse toggle */
.collapseToggle {
position: absolute;
top: 8px;
right: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--sidebar-muted);
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0;
z-index: 1;
transition: color 0.12s;
}
.collapseToggle:hover {
color: var(--sidebar-text);
}
/* Logo */
@@ -15,6 +45,12 @@
gap: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
overflow: hidden;
}
.sidebarCollapsed .logo {
padding: 16px 0;
justify-content: center;
}
.logoImg {
@@ -34,7 +70,7 @@
.version {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
color: var(--sidebar-muted);
margin-left: 4px;
}
@@ -106,71 +142,40 @@
background: rgba(255, 255, 255, 0.08);
}
/* Scrollable nav area */
.navArea {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Section headers */
.section {
padding: 14px 12px 5px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
/* Section icon (collapsed rail) */
.sectionIcon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
color: var(--sidebar-muted);
}
/* Items container */
.items {
padding: 0 6px;
}
/* Nav item (flat links like Dashboards) */
.item {
/* Rail item (collapsed sidebar section) */
.sectionRailItem {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-radius: var(--radius-sm);
color: var(--sidebar-text);
font-size: 13px;
justify-content: center;
padding: 10px 0;
cursor: pointer;
transition: all 0.12s;
border-left: 3px solid transparent;
margin-bottom: 1px;
user-select: none;
transition: background 0.12s;
}
.item:hover {
.sectionRailItem:hover {
background: var(--sidebar-hover);
color: #e8dfd4;
}
.item.active {
background: var(--sidebar-active);
color: var(--amber);
.sectionRailItemActive {
border-left-color: var(--amber);
}
.navIcon {
font-size: 14px;
width: 18px;
text-align: center;
color: var(--sidebar-muted);
flex-shrink: 0;
}
.item.active .navIcon {
.sectionRailItemActive .sectionIcon {
color: var(--amber);
}
.routeArrow {
color: var(--sidebar-muted);
font-size: 10px;
flex-shrink: 0;
.treeSectionActive {
border-left-color: var(--amber);
}
/* Item sub-elements */
@@ -200,15 +205,7 @@
padding: 0 6px 6px;
margin-bottom: 2px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.treeSectionLabel {
padding: 10px 12px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--sidebar-muted);
border-left: 3px solid transparent;
}
/* Collapsible section toggle */
@@ -218,6 +215,8 @@
gap: 2px;
width: 100%;
padding: 8px 0 4px;
cursor: pointer;
user-select: none;
}
.treeSectionChevronBtn {
@@ -239,7 +238,7 @@
}
.treeSectionLabel {
font-size: 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
@@ -314,7 +313,7 @@
padding: 0;
margin: 0;
color: var(--sidebar-muted);
font-size: 11px;
font-size: 12px;
cursor: pointer;
line-height: 1;
display: flex;
@@ -346,7 +345,7 @@
/* Badge */
.treeBadge {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
color: var(--sidebar-muted);
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
@@ -383,100 +382,13 @@
color: var(--amber);
}
/* ── Starred section ─────────────────────────────────────────────────────── */
.starredSection {
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.starredHeader {
color: var(--amber);
}
.starredList {
padding: 0 6px 6px;
}
.starredGroup {
margin-bottom: 4px;
}
.starredGroupLabel {
padding: 4px 12px 2px;
font-size: 10px;
color: var(--sidebar-muted);
font-weight: 500;
}
.starredItem {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: var(--radius-sm);
color: var(--sidebar-text);
font-size: 12px;
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.starredItem:hover {
background: var(--sidebar-hover);
}
.starredItemInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.starredItemName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.starredItemContext {
font-size: 10px;
color: var(--sidebar-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Remove button */
.starredRemove {
background: none;
border: none;
padding: 2px;
margin: 0;
color: var(--sidebar-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
display: flex;
align-items: center;
flex-shrink: 0;
}
.starredItem:hover .starredRemove {
opacity: 1;
}
.starredRemove:hover {
color: var(--error);
}
/* ── Bottom links ────────────────────────────────────────────────────────── */
.bottom {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 6px;
flex-shrink: 0;
margin-top: auto;
}
.bottomItem {

View File

@@ -1,172 +1,327 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Sidebar, type SidebarApp } from './Sidebar'
import { Sidebar } from './Sidebar'
import { ThemeProvider } from '../../providers/ThemeProvider'
const TEST_APPS: SidebarApp[] = [
{
id: 'order-service',
name: 'order-service',
health: 'live',
exchangeCount: 1433,
routes: [
{ id: 'order-intake', name: 'order-intake', exchangeCount: 892 },
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
],
agents: [
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: 14.2 },
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: 11.8 },
],
},
{
id: 'payment-svc',
name: 'payment-svc',
health: 'live',
exchangeCount: 912,
routes: [
{ id: 'payment-process', name: 'payment-process', exchangeCount: 414 },
],
agents: [],
},
]
// ── Helpers ─────────────────────────────────────────────────────────────────
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) {
return render(
const LogoIcon = () => <svg data-testid="logo-icon" />
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<MemoryRouter>
<Sidebar apps={TEST_APPS} {...props} />
</MemoryRouter>
</ThemeProvider>,
<MemoryRouter>{children}</MemoryRouter>
</ThemeProvider>
)
}
describe('Sidebar', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
// ── Tests ────────────────────────────────────────────────────────────────────
describe('Sidebar compound component', () => {
// 1. renders Header with logo, title, version
it('renders Header with logo, title, and version', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
</Sidebar>
</Wrapper>,
)
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
expect(screen.getByText('MyApp')).toBeInTheDocument()
expect(screen.getByText('v1.2.3')).toBeInTheDocument()
})
it('renders the logo and brand name', () => {
renderSidebar()
expect(screen.getByText('cameleer')).toBeInTheDocument()
expect(screen.getByText('v3.2.1')).toBeInTheDocument()
// 2. hides Header title and version when collapsed
it('hides Header title and version when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed>
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
</Sidebar>
</Wrapper>,
)
expect(screen.queryByText('MyApp')).not.toBeInTheDocument()
expect(screen.queryByText('v1.2.3')).not.toBeInTheDocument()
// Logo should still be visible
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
})
it('renders the search input', () => {
renderSidebar()
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument()
// 3. renders Section with label and children
it('renders Section with label and children when open', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>icon</span>}
label="Settings"
open
onToggle={vi.fn()}
>
<div>Section Child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('Section Child')).toBeInTheDocument()
})
it('renders Navigation section header', () => {
renderSidebar()
expect(screen.getByText('Navigation')).toBeInTheDocument()
// 4. hides Section children when section collapsed (open=false)
it('hides Section children when section is not open', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>icon</span>}
label="Settings"
open={false}
onToggle={vi.fn()}
>
<div>Section Child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.queryByText('Section Child')).not.toBeInTheDocument()
})
it('renders Applications tree section', () => {
renderSidebar()
expect(screen.getByText('Applications')).toBeInTheDocument()
// 5. calls onToggle when Section header clicked
it('calls onToggle when Section chevron button is clicked', async () => {
const user = userEvent.setup()
const onToggle = vi.fn()
render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>icon</span>}
label="Settings"
open
onToggle={onToggle}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const btn = screen.getByRole('button', { name: /collapse settings/i })
await user.click(btn)
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('renders Agents tree section', () => {
renderSidebar()
expect(screen.getByText('Agents')).toBeInTheDocument()
// 6. renders collapse toggle and calls onCollapseToggle
it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
const user = userEvent.setup()
const onCollapseToggle = vi.fn()
render(
<Wrapper>
<Sidebar onCollapseToggle={onCollapseToggle}>
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i })
await user.click(toggleBtn)
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
})
it('renders Routes nav link', () => {
renderSidebar()
expect(screen.getByText('Routes')).toBeInTheDocument()
// 7. renders expand toggle label when collapsed
it('renders expand toggle when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={vi.fn()}>
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument()
})
it('renders bottom links', () => {
renderSidebar()
// 8. renders search input and calls onSearchChange
it('renders search input and calls onSearchChange on input', async () => {
const user = userEvent.setup()
const onSearchChange = vi.fn()
render(
<Wrapper>
<Sidebar searchValue="" onSearchChange={onSearchChange}>
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
const input = screen.getByPlaceholderText('Filter...')
expect(input).toBeInTheDocument()
await user.type(input, 'hello')
expect(onSearchChange).toHaveBeenCalled()
// Each keystroke fires once
expect(onSearchChange.mock.calls[0][0]).toBe('h')
})
// 9. hides search when collapsed
it('hides search input when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed searchValue="" onSearchChange={vi.fn()}>
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
})
// 10. hides search when onSearchChange not provided
it('hides search input when onSearchChange is not provided', () => {
render(
<Wrapper>
<Sidebar searchValue="">
<Sidebar.Header logo={<LogoIcon />} title="App" />
</Sidebar>
</Wrapper>,
)
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
})
// 11. renders FooterLinks with icons and labels
it('renders FooterLinks with icon and label', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Footer>
<Sidebar.FooterLink
icon={<span data-testid="footer-icon">ic</span>}
label="Admin"
/>
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
expect(screen.getByTestId('footer-icon')).toBeInTheDocument()
expect(screen.getByText('Admin')).toBeInTheDocument()
expect(screen.getByText('API Docs')).toBeInTheDocument()
})
it('renders app names in the Applications tree', () => {
renderSidebar()
// order-service appears in Applications, Routes, and Agents trees
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
// 12. hides FooterLink labels when collapsed and sets title tooltip
it('hides FooterLink label when collapsed and exposes title tooltip', () => {
render(
<Wrapper>
<Sidebar collapsed>
<Sidebar.Footer>
<Sidebar.FooterLink
icon={<span>ic</span>}
label="Admin"
/>
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
// The clickable element should carry a title attribute for tooltip
// (accessible name comes from icon content when label is hidden)
const item = screen.getByTitle('Admin')
expect(item).toHaveAttribute('title', 'Admin')
})
it('renders exchange count badges', () => {
renderSidebar()
expect(screen.getByText('1.4k')).toBeInTheDocument()
})
it('renders agent live count badge in Agents tree', () => {
renderSidebar()
expect(screen.getByText('2/2 live')).toBeInTheDocument()
})
it('does not show starred section when nothing is starred', () => {
renderSidebar()
expect(screen.queryByText('★ Starred')).not.toBeInTheDocument()
})
it('shows starred section after starring an item', async () => {
// 13. calls FooterLink onClick
it('calls FooterLink onClick when clicked', async () => {
const user = userEvent.setup()
renderSidebar()
const onClick = vi.fn()
// Find the first app row (order-service in Applications tree) and hover to reveal star
const appRows = screen.getAllByText('order-service')
const appRow = appRows[0].closest('[role="treeitem"]')!
await user.hover(appRow)
render(
<Wrapper>
<Sidebar>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
// Click the star button
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')!
await user.click(starBtn)
expect(screen.getByText('★ Starred')).toBeInTheDocument()
await user.click(screen.getByText('Admin'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('filters tree items by search', async () => {
// 14. renders Section as icon-rail item when sidebar collapsed
it('renders Section as icon-rail item when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed>
<Sidebar.Section
icon={<span data-testid="section-icon">ic</span>}
label="Settings"
open={false}
onToggle={vi.fn()}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
// Label text should not be visible (only as tooltip via title attr)
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
// Rail item carries title attribute for tooltip
// (accessible name comes from icon content when label is hidden)
const railItem = screen.getByTitle('Settings')
expect(railItem).toHaveAttribute('title', 'Settings')
// Icon should still render
expect(screen.getByTestId('section-icon')).toBeInTheDocument()
// Section children should not be rendered
expect(screen.queryByText('child')).not.toBeInTheDocument()
})
// 15. fires both onCollapseToggle and onToggle when icon-rail section clicked
it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
const user = userEvent.setup()
renderSidebar()
const onCollapseToggle = vi.fn()
const onToggle = vi.fn()
const searchInput = screen.getByPlaceholderText('Filter...')
await user.type(searchInput, 'payment')
render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={onCollapseToggle}>
<Sidebar.Section
icon={<span>ic</span>}
label="Settings"
open={false}
onToggle={onToggle}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
// payment-svc should still be visible (may appear in multiple trees)
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
const railItem = screen.getByTitle('Settings')
await user.click(railItem)
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('expands tree to show children when chevron is clicked', async () => {
const user = userEvent.setup()
renderSidebar()
// 16. applies active highlight to FooterLink
it('applies active highlight class to FooterLink when active', () => {
render(
<Wrapper>
<Sidebar>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" active />
</Sidebar.Footer>
</Sidebar>
</Wrapper>,
)
// Find the expand button for order-service in Applications tree
const expandBtns = screen.getAllByLabelText('Expand')
await user.click(expandBtns[0])
// Routes should now be visible
expect(screen.getByText('order-intake')).toBeInTheDocument()
expect(screen.getByText('order-enrichment')).toBeInTheDocument()
})
it('collapses expanded tree when chevron is clicked again', async () => {
const user = userEvent.setup()
renderSidebar()
const expandBtns = screen.getAllByLabelText('Expand')
await user.click(expandBtns[0])
expect(screen.getByText('order-intake')).toBeInTheDocument()
const collapseBtn = screen.getByLabelText('Collapse')
await user.click(collapseBtn)
expect(screen.queryByText('order-intake')).not.toBeInTheDocument()
})
it('does not render apps with no agents in the Agents tree', () => {
renderSidebar()
// payment-svc has no agents, so it shouldn't appear under the Agents section header
// But it still appears under Applications. Let's check the agent tree specifically.
const agentBadges = screen.queryAllByText(/\/.*live/)
// Only order-service should have an agent badge
expect(agentBadges).toHaveLength(1)
expect(agentBadges[0].textContent).toBe('2/2 live')
const item = screen.getByText('Admin').closest('[role="button"]')!
expect(item.className).toMatch(/bottomItemActive/)
})
})

View File

@@ -1,563 +1,253 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { type ReactNode, Children, isValidElement } from 'react'
import {
Search,
X,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react'
import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg'
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
import { useStarred } from './useStarred'
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
import { SidebarContext, useSidebarContext } from './SidebarContext'
// ── Types ────────────────────────────────────────────────────────────────────
// ── Sub-component props ─────────────────────────────────────────────────────
export interface SidebarApp {
id: string
name: string
health: 'live' | 'stale' | 'dead'
exchangeCount: number
routes: SidebarRoute[]
agents: SidebarAgent[]
}
export interface SidebarRoute {
id: string
name: string
exchangeCount: number
}
export interface SidebarAgent {
id: string
name: string
status: 'live' | 'stale' | 'dead'
tps: number
}
interface SidebarProps {
apps: SidebarApp[]
interface SidebarHeaderProps {
logo: ReactNode
title: string
version?: string
onClick?: () => void
className?: string
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps.map((app) => ({
id: `app:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: formatCount(app.exchangeCount),
path: `/apps/${app.id}`,
starrable: true,
starKey: app.id,
children: app.routes.map((route) => ({
id: `route:${app.id}:${route.id}`,
starKey: `${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/apps/${app.id}/${route.id}`,
starrable: true,
})),
}))
}
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.routes.length > 0)
.map((app) => ({
id: `routes:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: `${app.routes.length} routes`,
path: `/routes/${app.id}`,
starrable: true,
starKey: `routes:${app.id}`,
children: app.routes.map((route) => ({
id: `routestat:${app.id}:${route.id}`,
starKey: `routes:${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
starrable: true,
})),
}))
}
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.agents.length > 0)
.map((app) => {
const liveCount = app.agents.filter((a) => a.status === 'live').length
return {
id: `agents:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: `${liveCount}/${app.agents.length} live`,
path: `/agents/${app.id}`,
starrable: true,
starKey: `agents:${app.id}`,
children: app.agents.map((agent) => ({
id: `agent:${app.id}:${agent.id}`,
starKey: `${app.id}:${agent.id}`,
label: agent.name,
badge: `${agent.tps.toFixed(1)}/s`,
path: `/agents/${app.id}/${agent.id}`,
starrable: true,
})),
}
})
}
// ── Starred section helpers ──────────────────────────────────────────────────
interface StarredItem {
starKey: string
interface SidebarSectionProps {
icon: ReactNode
label: string
icon?: React.ReactNode
path: string
type: 'application' | 'route' | 'agent' | 'routestat'
parentApp?: string
open: boolean
onToggle: () => void
active?: boolean
children: ReactNode
className?: string
}
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
const items: StarredItem[] = []
for (const app of apps) {
if (starredIds.has(app.id)) {
items.push({
starKey: app.id,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/apps/${app.id}`,
type: 'application',
})
}
for (const route of app.routes) {
const key = `${app.id}:${route.id}`
if (starredIds.has(key)) {
items.push({
starKey: key,
label: route.name,
path: `/apps/${app.id}/${route.id}`,
type: 'route',
parentApp: app.name,
})
}
}
const agentsAppKey = `agents:${app.id}`
if (starredIds.has(agentsAppKey)) {
items.push({
starKey: agentsAppKey,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/agents/${app.id}`,
type: 'agent',
})
}
for (const agent of app.agents) {
const key = `${app.id}:${agent.id}`
if (starredIds.has(key)) {
items.push({
starKey: key,
label: agent.name,
path: `/agents/${app.id}/${agent.id}`,
type: 'agent',
parentApp: app.name,
})
}
}
// Routes tree starred items
const routesAppKey = `routes:${app.id}`
if (starredIds.has(routesAppKey)) {
items.push({
starKey: routesAppKey,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/routes/${app.id}`,
type: 'routestat',
})
}
for (const route of app.routes) {
const routeKey = `routes:${app.id}:${route.id}`
if (starredIds.has(routeKey)) {
items.push({
starKey: routeKey,
label: route.name,
path: `/routes/${app.id}/${route.id}`,
type: 'routestat',
parentApp: app.name,
})
}
}
}
return items
interface SidebarFooterProps {
children: ReactNode
className?: string
}
// ── StarredGroup ─────────────────────────────────────────────────────────────
function StarredGroup({
label,
items,
onNavigate,
onRemove,
}: {
interface SidebarFooterLinkProps {
icon: ReactNode
label: string
items: StarredItem[]
onNavigate: (path: string) => void
onRemove: (starKey: string) => void
}) {
active?: boolean
onClick?: () => void
className?: string
}
interface SidebarRootProps {
collapsed?: boolean
onCollapseToggle?: () => void
searchValue?: string
onSearchChange?: (query: string) => void
children: ReactNode
className?: string
}
// ── Sub-components ──────────────────────────────────────────────────────────
function SidebarHeader({ logo, title, version, onClick, className }: SidebarHeaderProps) {
const { collapsed } = useSidebarContext()
return (
<div className={styles.starredGroup}>
<div className={styles.starredGroupLabel}>{label}</div>
{items.map((item) => (
<div
key={item.starKey}
className={styles.starredItem}
onClick={() => onNavigate(item.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
className={`${styles.logo} ${className ?? ''}`}
onClick={onClick}
style={onClick ? { cursor: 'pointer' } : undefined}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
>
{item.icon}
<div className={styles.starredItemInfo}>
<span className={styles.starredItemName}>{item.label}</span>
{item.parentApp && (
<span className={styles.starredItemContext}>{item.parentApp}</span>
{logo}
{!collapsed && (
<div>
<span className={styles.brand}>{title}</span>
{version && <span className={styles.version}>{version}</span>}
</div>
)}
</div>
<button
className={styles.starredRemove}
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
tabIndex={-1}
aria-label={`Remove ${item.label} from starred`}
)
}
function SidebarSection({
icon,
label,
open,
onToggle,
active,
children,
className,
}: SidebarSectionProps) {
const { collapsed, onCollapseToggle } = useSidebarContext()
// In icon-rail (collapsed) mode, render a centered icon with tooltip
if (collapsed) {
return (
<div
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
title={label}
onClick={() => {
// Expand sidebar and open the section
onCollapseToggle?.()
onToggle()
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onCollapseToggle?.()
onToggle()
}
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<span className={styles.sectionIcon}>{icon}</span>
</div>
))}
)
}
return (
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
<div
className={styles.treeSectionToggle}
onClick={onToggle}
role="button"
tabIndex={0}
aria-expanded={open}
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }}
>
{icon && <span className={styles.sectionIcon}>{icon}</span>}
<span className={styles.treeSectionLabel}>{label}</span>
</div>
{open && children}
</div>
)
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
export function Sidebar({ apps, className }: SidebarProps) {
const [search, setSearch] = useState('')
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
_setAppsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
return next
})
}
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
_setAgentsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
return next
})
}
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
_setRoutesCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
return next
})
}
const navigate = useNavigate()
const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
// Build tree data
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
// Sidebar reveal from Cmd-K navigation (passed via location state)
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
useEffect(() => {
if (!sidebarRevealPath) return
// Uncollapse Applications section if reveal path matches an apps tree node
const matchesAppTree = appNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
function SidebarFooter({ children, className }: SidebarFooterProps) {
return (
<div className={`${styles.bottom} ${className ?? ''}`}>
{children}
</div>
)
if (matchesAppTree && appsCollapsed) {
_setAppsCollapsed(false)
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
}
}
// Uncollapse Agents section if reveal path matches an agents tree node
const matchesAgentTree = agentNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
if (matchesAgentTree && agentsCollapsed) {
_setAgentsCollapsed(false)
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
}
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
// Build starred items
const starredItems = useMemo(
() => collectStarredItems(apps, starredIds),
[apps, starredIds],
)
const starredApps = starredItems.filter((i) => i.type === 'application')
const starredRoutes = starredItems.filter((i) => i.type === 'route')
const starredAgents = starredItems.filter((i) => i.type === 'agent')
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
const hasStarred = starredItems.length > 0
// For exchange detail pages, use the reveal path for sidebar selection so
// the parent route is highlighted (exchanges have no sidebar entry of their own)
const effectiveSelectedPath = location.pathname.startsWith('/exchanges/') && sidebarRevealPath
? sidebarRevealPath
: location.pathname
function SidebarFooterLink({ icon, label, active, onClick, className }: SidebarFooterLinkProps) {
const { collapsed } = useSidebarContext()
return (
<aside className={`${styles.sidebar} ${className ?? ''}`}>
{/* Logo */}
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
<div>
<span className={styles.brand}>cameleer</span>
<span className={styles.version}>v3.2.1</span>
<div
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>
)
}
{/* Search */}
// ── 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">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<Search size={12} />
</span>
<input
className={styles.searchInput}
type="text"
placeholder="Filter..."
value={search}
onChange={(e) => setSearch(e.target.value)}
value={searchValue ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
/>
{search && (
{searchValue && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
onClick={() => onSearchChange('')}
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 ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/apps')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
>
Applications
</span>
</div>
{!appsCollapsed && (
<SidebarTree
nodes={appNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
{/* Agents tree (collapsible, label navigates to /agents) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
>
{agentsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/agents')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
>
Agents
</span>
</div>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
{/* Routes tree (collapsible, label navigates to /routes) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setRoutesCollapsed((v) => !v)}
aria-expanded={!routesCollapsed}
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
>
{routesCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/routes')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
>
Routes
</span>
</div>
{!routesCollapsed && (
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
/>
)}
</div>
{/* No results message */}
{search && appNodes.length === 0 && agentNodes.length === 0 && (
<div className={styles.noResults}>No results</div>
)}
{/* Starred section (inside scrollable area, hidden when empty) */}
{hasStarred && (
<div className={styles.starredSection}>
<div className={styles.section}>
<span className={styles.starredHeader}> Starred</span>
</div>
<div className={styles.starredList}>
{starredApps.length > 0 && (
<StarredGroup
label="Applications"
items={starredApps}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredRoutes.length > 0 && (
<StarredGroup
label="Routes"
items={starredRoutes}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredAgents.length > 0 && (
<StarredGroup
label="Agents"
items={starredAgents}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredRouteStats.length > 0 && (
<StarredGroup
label="Routes"
items={starredRouteStats}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
</div>
</div>
)}
</div>
{/* Bottom links */}
<div className={styles.bottom}>
<div
className={[
styles.bottomItem,
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/admin')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
>
<span className={styles.bottomIcon}>&#9881;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>Admin</div>
</div>
</div>
<div
className={[
styles.bottomItem,
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/api-docs')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
>
<span className={styles.bottomIcon}>&#9776;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div>
</div>
</div>
</div>
{rest}
</>
)
})()}
</aside>
</SidebarContext.Provider>
)
}
// ── Compound export ─────────────────────────────────────────────────────────
export const Sidebar = Object.assign(SidebarRoot, {
Header: SidebarHeader,
Section: SidebarSection,
Footer: SidebarFooter,
FooterLink: SidebarFooterLink,
})

View 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)
}

View File

@@ -9,6 +9,7 @@ import {
type MouseEvent,
} from 'react'
import { useNavigate } from 'react-router-dom'
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
import styles from './Sidebar.module.css'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -33,24 +34,17 @@ export interface SidebarTreeProps {
filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
onNavigate?: (path: string) => void
}
// ── Star icon SVGs ───────────────────────────────────────────────────────────
// ── Star icons ───────────────────────────────────────────────────────────────
function StarOutline() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
return <Star size={14} />
}
function StarFilled() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
return <Star size={14} fill="currentColor" />
}
// ── Persistent expand state ──────────────────────────────────────────────────
@@ -130,6 +124,52 @@ function filterNodes(
return { filtered: walk(nodes), matchedParentIds }
}
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
}
function handleArrowRight(
current: FlatNode | undefined,
currentIndex: number,
expandedSet: Set<string>,
visibleNodes: FlatNode[],
handleToggle: (id: string) => void,
focusNode: (id: string) => void,
) {
if (!current) return
const hasChildren = current.node.children && current.node.children.length > 0
if (!hasChildren) return
if (!expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
function handleArrowLeft(
current: FlatNode | undefined,
expandedSet: Set<string>,
handleToggle: (id: string) => void,
focusNode: (id: string) => void,
) {
if (!current) return
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else if (current.parentId !== null) {
focusNode(current.parentId)
}
}
// ── SidebarTree ──────────────────────────────────────────────────────────────
export function SidebarTree({
@@ -141,8 +181,10 @@ export function SidebarTree({
filterQuery,
persistKey,
autoRevealPath,
onNavigate,
}: SidebarTreeProps) {
const navigate = useNavigate()
const routerNavigate = useNavigate()
const navigate = onNavigate ?? routerNavigate
// Expand/collapse state — optionally persisted to sessionStorage
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
@@ -226,64 +268,13 @@ export function SidebarTree({
const current = visibleNodes[currentIndex]
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
break
}
case 'ArrowRight': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren) {
if (!expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
break
}
case 'ArrowLeft': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else if (current.parentId !== null) {
focusNode(current.parentId)
}
break
}
case 'Enter': {
e.preventDefault()
if (current?.node.path) {
navigate(current.node.path)
}
break
}
case 'Home': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[0].node.id)
}
break
}
case 'End': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
}
break
}
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
case 'Enter': { e.preventDefault(); if (current?.node.path) navigate(current.node.path); break }
case 'Home': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[0].node.id); break }
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -375,6 +366,10 @@ function SidebarTreeRow({
return (
<li role="none">
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
onKeyDown={handleKeyDown} which handles Enter (navigate) and all arrow keys
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
fire the action twice. */}
<div
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}
@@ -395,7 +390,7 @@ function SidebarTreeRow({
tabIndex={-1}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? '▾' : '▸'}
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : null}
</span>

View 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>
)
}

View 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... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
)
}

View File

@@ -62,7 +62,7 @@
.kbd {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
padding: 0 4px;
@@ -91,7 +91,7 @@
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
@@ -153,16 +153,8 @@
}
.env {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
background: var(--success-bg);
color: var(--success);
border: 1px solid var(--success-border);
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
}
.user {

View File

@@ -1,101 +1,45 @@
import { type ReactNode } from 'react'
import { Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown'
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'
interface BreadcrumbItem {
label: string
href?: string
}
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
interface TopBarProps {
breadcrumb: BreadcrumbItem[]
environment?: string
environment?: ReactNode
user?: { name: string }
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
onLogout?: () => void
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({
breadcrumb,
environment,
user,
userMenuItems,
onLogout,
className,
children,
}: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme()
const breadcrumbOverride = useBreadcrumbOverride()
return (
<header className={`${styles.topbar} ${className ?? ''}`}>
{/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
{/* Search trigger */}
<button
className={styles.search}
onClick={() => commandPalette.setOpen(true)}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
{/* Center: consumer-provided controls */}
{children}
{/* Status filter group */}
<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 */}
{/* Right: theme toggle, env badge, user */}
<div className={styles.right}>
<button
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
type="button"
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
</button>
<button
className={styles.themeToggle}
onClick={toggleTheme}
@@ -103,10 +47,10 @@ export function TopBar({
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '\u263E' : '\u2600'}
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</button>
{environment && (
<span className={styles.env}>{environment}</span>
<div className={styles.env}>{environment}</div>
)}
{user && (
<Dropdown
@@ -117,7 +61,9 @@ export function TopBar({
</div>
}
items={[
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
...(userMenuItems ?? []),
...(userMenuItems?.length ? [{ label: '', divider: true }] : []),
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
]}
/>
)}

View File

@@ -1,4 +1,8 @@
export { AppShell } from './AppShell/AppShell'
export { Sidebar } from './Sidebar/Sidebar'
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred'
export { TopBar } from './TopBar/TopBar'
export { SearchTrigger } from './TopBar/SearchTrigger'
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'

View File

@@ -46,24 +46,23 @@ describe('Alert', () => {
})
it('shows default icon for each variant', () => {
const { rerender } = render(<Alert variant="info">msg</Alert>)
expect(screen.getByText('')).toBeInTheDocument()
const { container, rerender } = render(<Alert variant="info">msg</Alert>)
// Each variant should render an SVG icon in the icon slot
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="success">msg</Alert>)
expect(screen.getByText('✓')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="warning">msg</Alert>)
expect(screen.getByText('⚠')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="error">msg</Alert>)
expect(screen.getByText('✕')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
})
it('renders a custom icon when provided', () => {
render(<Alert icon={<span></span>}>Custom icon alert</Alert>)
expect(screen.getByText('')).toBeInTheDocument()
// Default icon should not appear
expect(screen.queryByText('')).not.toBeInTheDocument()
render(<Alert icon={<span data-testid="custom-icon"></span>}>Custom icon alert</Alert>)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('does not show dismiss button when dismissible is false', () => {

View File

@@ -1,4 +1,5 @@
import { ReactNode } from 'react'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Alert.module.css'
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
@@ -13,11 +14,11 @@ interface AlertProps {
className?: string
}
const DEFAULT_ICONS: Record<AlertVariant, string> = {
info: '',
success: '✓',
warning: '⚠',
error: '✕',
const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
info: <Info size={16} />,
success: <CheckCircle size={16} />,
warning: <AlertTriangle size={16} />,
error: <XCircle size={16} />,
}
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
@@ -61,7 +62,7 @@ export function Alert({
aria-label="Dismiss alert"
type="button"
>
&times;
<X size={14} />
</button>
)}
</div>

View File

@@ -8,5 +8,5 @@
flex-shrink: 0;
}
.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; }

View File

@@ -5,7 +5,7 @@
padding: 1px 8px;
border-radius: 10px;
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
border: 1px solid transparent;

View File

@@ -13,7 +13,7 @@
.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; }
.primary {

View File

@@ -18,7 +18,7 @@
}
.titleText {
font-size: 11px;
font-size: 12px;
text-transform: uppercase;
font-family: var(--font-mono);
font-weight: 600;

View File

@@ -26,7 +26,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
@@ -49,5 +49,5 @@
user-select: none;
text-align: right;
margin-right: 12px;
font-size: 11px;
font-size: 12px;
}

View File

@@ -5,7 +5,7 @@
}
.label {
font-size: 11px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
@@ -18,7 +18,7 @@
background: transparent;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
@@ -72,7 +72,7 @@
border: none;
background: none;
color: var(--text-muted);
font-size: 10px;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
@@ -93,7 +93,7 @@
}
.dayHeader {
font-size: 10px;
font-size: 12px;
font-weight: 600;
color: var(--text-faint);
text-align: center;
@@ -151,7 +151,7 @@
}
.timeLabel {
font-size: 11px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
margin-right: auto;

View File

@@ -0,0 +1,64 @@
.wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1.5px dashed var(--border);
border-radius: var(--radius-sm);
background: var(--bg-inset);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.wrap:hover {
border-color: var(--text-muted);
}
.dragOver {
border-color: var(--amber);
background: var(--amber-bg);
}
.icon {
color: var(--text-faint);
flex-shrink: 0;
line-height: 0;
}
.label {
font-family: var(--font-body);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text-faint);
}
.fileName {
color: var(--text-primary);
}
.clearBtn {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0;
transition: color 0.1s, background 0.1s;
}
.clearBtn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}

View File

@@ -0,0 +1,99 @@
import styles from './FileInput.module.css'
import { forwardRef, useRef, useState, useImperativeHandle, type ReactNode } from 'react'
import { X } from 'lucide-react'
export interface FileInputProps {
/** File type filter, e.g. ".pem,.crt,.cer" */
accept?: string
/** Icon rendered before the label */
icon?: ReactNode
/** Placeholder text when no file is selected */
placeholder?: string
/** Additional CSS class */
className?: string
/** Called when a file is selected or cleared */
onChange?: (file: File | null) => void
}
export interface FileInputHandle {
/** The currently selected File, or null */
file: File | null
/** Programmatically clear the selection */
clear: () => void
}
export const FileInput = forwardRef<FileInputHandle, FileInputProps>(
({ accept, icon, placeholder = 'Drop file or click to browse', className, onChange }, ref) => {
const inputRef = useRef<HTMLInputElement>(null)
const [fileName, setFileName] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileRef = useRef<File | null>(null)
function select(file: File | null) {
fileRef.current = file
setFileName(file?.name ?? null)
onChange?.(file)
}
function handleInputChange() {
select(inputRef.current?.files?.[0] ?? null)
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file && inputRef.current) {
const dt = new DataTransfer()
dt.items.add(file)
inputRef.current.files = dt.files
select(file)
}
}
function handleClear(e: React.MouseEvent) {
e.stopPropagation()
if (inputRef.current) inputRef.current.value = ''
select(null)
}
useImperativeHandle(ref, () => ({
get file() { return fileRef.current },
clear() {
if (inputRef.current) inputRef.current.value = ''
select(null)
},
}))
const wrapClass = [styles.wrap, dragOver && styles.dragOver, className].filter(Boolean).join(' ')
return (
<div
className={wrapClass}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{icon && <span className={styles.icon}>{icon}</span>}
<span className={`${styles.label} ${fileName ? styles.fileName : styles.placeholder}`}>
{fileName ?? placeholder}
</span>
{fileName && (
<button type="button" className={styles.clearBtn} onClick={handleClear} aria-label="Clear file">
<X size={12} />
</button>
)}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleInputChange}
style={{ display: 'none' }}
/>
</div>
)
},
)
FileInput.displayName = 'FileInput'

View File

@@ -8,7 +8,7 @@
background: var(--bg-raised);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 11px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
@@ -49,7 +49,7 @@
.count {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
font-weight: 600;
background: var(--bg-inset);
border-radius: 8px;

View File

@@ -6,13 +6,13 @@
}
.hint {
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.error {
font-size: 11px;
font-size: 12px;
color: var(--error);
margin-top: 4px;
}

View File

@@ -59,7 +59,15 @@ export function InlineEdit({ value, onSave, placeholder, disabled, className }:
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
<span
className={isEmpty ? styles.placeholder : styles.value}
role="button"
tabIndex={disabled ? undefined : 0}
onClick={startEdit}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
startEdit()
}
}}
>
{isEmpty ? placeholder : value}
</span>

View File

@@ -1,5 +1,6 @@
import styles from './Input.module.css'
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
import { X } from 'lucide-react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
onClick={onClear}
aria-label="Clear search"
>
×
<X size={12} />
</button>
)}
</div>

View File

@@ -1,6 +1,6 @@
.kbd {
font-family: var(--font-mono);
font-size: 10px;
font-size: 12px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 3px;

View File

@@ -1,4 +1,4 @@
.mono { font-family: var(--font-mono); }
.xs { font-size: 10px; }
.sm { font-size: 11px; }
.xs { font-size: 12px; }
.sm { font-size: 12px; }
.md { font-size: 13px; }

Some files were not shown because too many files have changed in this diff Show More