Compare commits

...

34 Commits

Author SHA1 Message Date
hsiegeln
b959edd6c7 feat: add logout dropdown to user menu in TopBar
All checks were successful
Build & Publish / publish (push) Successful in 47s
Click user name/avatar to open dropdown with Logout option.
TopBar accepts optional onLogout callback prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:14:40 +01:00
hsiegeln
ff9f1aa519 fix(ci): use POSIX-compatible case statement for tag detection
All checks were successful
Build & Publish / publish (push) Successful in 44s
The Gitea runner uses sh, not bash — [[ ]] syntax fails silently
causing all pushes to publish as snapshots instead of tagged releases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:28:49 +01:00
hsiegeln
91788737b0 feat: add ButtonGroup, theme toggle, dark theme fixes, remove shift references
Some checks failed
Build & Publish / publish (push) Failing after 45s
- Add ButtonGroup primitive: multi-select toggle with colored dot indicators
- Replace FilterPill status filters with ButtonGroup in TopBar and EventFeed
- Add light/dark mode toggle to TopBar (moon/sun icon)
- Fix dark theme: add --purple/--purple-bg tokens, replace all hardcoded
  #F3EEFA/#7C3AED with tokens, fix --amber-light text contrast in sidebar,
  brighten --sidebar-text/--sidebar-muted tokens, use color-mix for
  ProcessorTimeline bar fills
- Remove all "shift" references (presets, labels, badges)
- Shrink SegmentedTabs height to match search bar and ButtonGroup
- Update COMPONENT_GUIDE.md with new components and updated descriptions
- Add ButtonGroup demo to Inventory
- Add README.md with setup instructions and navigation guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:33:34 +01:00
hsiegeln
5bd965e59a style: add horizontal dividers between sidebar tree sections
All checks were successful
Build & Publish / publish (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:39:45 +01:00
hsiegeln
e21d920fe3 fix: enable starring for Routes tree and top-level Agents nodes
All checks were successful
Build & Publish / publish (push) Successful in 44s
- Routes tree nodes now have starrable: true at both app and route levels
- Add starred Routes group to the starred section
- Fix missing starred collection for top-level agent application nodes
- Fix agent starred path from /agents/:id to /agents/:appId/:id

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:35:40 +01:00
hsiegeln
a92ada8117 feat: rework Metrics into Routes with 3-level hierarchy and mock-matching KPI header
All checks were successful
Build & Publish / publish (push) Successful in 43s
- Rename Metrics to Routes with /routes, /routes/:appId, /routes/:appId/:routeId
- Sidebar: Routes is now a collapsible tree (apps > routes) like Applications/Agents
- KPI header matching mock-v3-metrics-dashboard: throughput with sparkline, error rate,
  latency percentiles (P50/P95/P99), active routes with mini donut, in-flight exchanges
- Same KPI header used consistently across all 3 levels with scoped data
- Route detail level shows per-processor performance table and RouteFlow diagram
- Added appId to RouteMetricRow and filled missing route entries in mock data
- Fix sidebar section toggle indentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:29:27 +01:00
hsiegeln
932dc9dcbd feat: redesign exchange detail page with interactive processor inspector
All checks were successful
Build & Publish / publish (push) Successful in 44s
- Rewrite ExchangeDetail with split Message IN/OUT panels that update
  on processor click, error panel for failed processors, and
  Timeline/Flow toggle for the processor visualization
- Add correlation chain in header with status-colored clickable nodes
  sorted by start time, labeled "Correlated Exchanges"
- Add Exchange ID column and inspect button (↗) to Dashboard table
- Add "Open full details" link in the exchange slide-in panel
- Add selectedIndex prop to ProcessorTimeline and RouteFlow for
  highlighting the active processor
- Add onNodeClick + selectedIndex to RouteFlow for interactive use
- Add correlationGroup field to exchange mock data
- Fix sidebar section toggle indentation alignment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:15:28 +01:00
hsiegeln
9c9063dc1b refactor: unify /apps routing with application and route filtering
All checks were successful
Build & Publish / publish (push) Successful in 44s
- Table columns: Status, Route, Application, Started (yyyy-mm-dd hh:mm:ss),
  Duration, Agent (removed Order ID and Customer)
- /apps shows all exchanges, /apps/:id filters by application,
  /apps/:id/:routeId filters by application and route
- Route paths changed from /routes/:id to /apps/:appId/:routeId across
  sidebar, search, breadcrumbs, metrics, and exchange detail
- Added buildRouteToAppMap utility for route→application lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:39:45 +01:00
hsiegeln
4f3e9c0f35 feat: add RouteFlow component and replace tabbed exchange detail with stacked layout
All checks were successful
Build & Publish / publish (push) Successful in 43s
Replace the 4-tab DetailPanel (Overview/Processors/Exchange/Error) with a
single scrollable view: overview, errors (limited to 1 with +N indicator),
route flow diagram, and processor timeline. DetailPanel now supports
children as an alternative to tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:25:01 +01:00
hsiegeln
daf53ad499 feat: add SegmentedTabs, custom DateTimePicker, redesign time range selector
All checks were successful
Build & Publish / publish (push) Successful in 44s
New components:
- SegmentedTabs — pill-style tabs with sliding animated indicator,
  trailing slot for custom content, MutationObserver for dynamic resizing
- Custom DateTimePicker — replaces native datetime-local with calendar
  grid, hour/minute inputs, Now/Apply buttons, portal dropdown

Time range selector redesign:
- Uses SegmentedTabs with inline from/to DateTimePicker triggers
- "now" shown as clickable placeholder when to-date is not explicitly set
- Preset selection keeps to-date as "now" until user sets it
- No more "Custom" button — last tab is the live date range

Other improvements:
- FilterPill gains activeColor prop for status-colored active states
- TopBar and EventFeed status pills now use colored dots + activeColor
- Inventory nav expanded to full component-level table of contents
- COMPONENT_GUIDE.md updated with new components
- DateRangePicker test updated for custom DateTimePicker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:39:54 +01:00
hsiegeln
8418b89a77 feat: add colored active state to status filter pills
All checks were successful
Build & Publish / publish (push) Successful in 43s
FilterPill gains activeColor prop — when active, border/text/background
tint match the status color instead of generic amber. Inactive dots
are muted at 40% opacity for clear active/inactive contrast.

Applied to TopBar status pills and EventFeed severity pills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:58:28 +01:00
hsiegeln
fdf45d0d94 feat: add AgentInstance detail page and improve AgentHealth
All checks were successful
Build & Publish / publish (push) Successful in 43s
- New /agents/:appId/:instanceId page with process info, 3x2 charts
  grid (CPU, memory, throughput, errors, threads, GC), application
  log viewer with level filtering, and instance-scoped timeline
- AgentHealth now uses slide-in DetailPanel for quick instance preview
- Stat strip enhanced: colored StatusDot breakdowns, route ratio with
  state-colored values, Groups renamed to Applications
- Unified page structure: stat strip → scope trail with inline badges
  (removed duplicate section headers from both pages)
- StatCard value/detail props now accept ReactNode
- Log and timeline displayed side by side

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:51:13 +01:00
hsiegeln
d9483ec4d1 refactor: AgentHealth slide-in detail panel and richer stat cards
All checks were successful
Build & Publish / publish (push) Successful in 43s
- Instance detail now opens in a DetailPanel (slide-in from right)
  with Overview and Performance tabs instead of navigating away
- Stat strip matches mock design: 5 cards with colored StatusDot
  breakdowns, labeled states (live/stale/dead, healthy/degraded/critical)
- Active Routes shows colored ratio (green/yellow/red) based on state
- Groups renamed to Applications
- StatCard value/detail props now accept ReactNode for rich content

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:23:13 +01:00
hsiegeln
f075968e66 refactor: admin section UX/UI redesign
All checks were successful
Build & Publish / publish (push) Successful in 43s
- Fix critical --bg-base token bug (dark mode broken), replace with --bg-surface
- Replace hand-rolled admin nav with Tabs composite (proper ARIA)
- Migrate AuditLog from custom table to DataTable with sorting, row accents, card wrapper
- Remove duplicate h2 page titles (breadcrumb + tab already identify the page)
- Rework user creation with provider-aware form (Local/OIDC RadioGroup)
- Add Security section with password reset for local users, OIDC info for external
- Add toast notifications to all RBAC mutations (create/delete/add/remove)
- Add confirmation dialogs for cascading removals (group/role)
- Add keyboard accessibility to entity lists (role/tabIndex/aria-selected)
- Add empty search states, duplicate name validation
- Replace lock emoji with Badge, fix radii/shadow/padding consistency
- Badge dashed variant keeps background color
- Inherited roles shown with dashed outline + reduced opacity
- Inline MultiSelect (+Add) for groups, roles, members, child groups
- Center OIDC form, replace inline styles with CSS modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:44:19 +01:00
hsiegeln
544b82301a docs: add admin redesign implementation plan
11-task plan covering token fix, Tabs migration, DataTable migration,
title cleanup, user creation rework with provider selection, password
management, toast feedback, accessibility, and cascading confirmations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:36:48 +01:00
hsiegeln
4526d4c7ef docs: add admin section redesign spec
Based on 3-expert UX/UI review: fixes critical --bg-base bug,
migrates AuditLog to DataTable, replaces admin nav with Tabs,
reworks user creation with provider-aware flow, adds password
management, toast feedback, and accessibility improvements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:33:37 +01:00
hsiegeln
646551cb93 feat: add RolesTab to User Management
All checks were successful
Build & Publish / publish (push) Successful in 44s
2026-03-18 23:12:44 +01:00
hsiegeln
a173c5b6ce feat: add GroupsTab to User Management 2026-03-18 23:12:23 +01:00
hsiegeln
016c92ec4f feat: add UserManagement container and UsersTab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:10:58 +01:00
hsiegeln
1ec7ace4e3 feat: add OIDC Config admin page 2026-03-18 23:09:16 +01:00
hsiegeln
af3219a7df feat: add Audit Log admin page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:08:53 +01:00
hsiegeln
cffda9a5a7 feat: add InlineEdit, ConfirmDialog, MultiSelect to Inventory demos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:08:27 +01:00
hsiegeln
f7d30c1257 feat: add ConfirmDialog composite component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:06:38 +01:00
hsiegeln
f9addff5a6 feat: add MultiSelect composite component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:05:38 +01:00
hsiegeln
7a49a0b1db feat: add admin layout with sub-navigation and routing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:04:28 +01:00
hsiegeln
6a404ddd53 feat: add RBAC mock data with users, groups, roles 2026-03-18 23:03:59 +01:00
hsiegeln
bef93f4fe8 feat: add audit log mock data 2026-03-18 23:03:46 +01:00
hsiegeln
20a5d2030e feat: add InlineEdit primitive component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:02:44 +01:00
hsiegeln
c76ae79d7a test: add ConfirmDialog test file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:01:45 +01:00
hsiegeln
e2db46fc98 test: add MultiSelect test file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:01:43 +01:00
hsiegeln
8695b9b878 test: add InlineEdit test file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:01:03 +01:00
hsiegeln
fc835ef3f9 docs: add admin pages implementation plan
16-task plan covering InlineEdit, ConfirmDialog, MultiSelect components,
admin layout/routing, AuditLog, OidcConfig, UserManagement pages,
inventory demos, and integration verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:59:10 +01:00
hsiegeln
df5450925e docs: address spec review feedback for admin pages design
- Move MultiSelect to composites (depends on portal, not a primitive)
- MultiSelect manages own positioning instead of wrapping Popover
- Add loading prop and info variant to ConfirmDialog
- Drop forwardRef from InlineEdit (input conditionally exists)
- Change InlineEdit blur to cancel (not save)
- Add router integration, barrel export, and accessibility details
- Add sidebar integration strategy (admin sub-nav, not sidebar clutter)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:46:45 +01:00
hsiegeln
d41961dbe2 docs: add admin pages + new components design spec
Covers MultiSelect, ConfirmDialog, InlineEdit components and
AuditLog, OidcConfig, UserManagement admin example pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:43:24 +01:00
82 changed files with 12225 additions and 1446 deletions

View File

@@ -23,17 +23,21 @@ jobs:
run: npm run build:lib run: npm run build:lib
- name: Publish package - name: Publish package
shell: bash
run: | run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then case "$GITHUB_REF" in
VERSION="${GITHUB_REF_NAME#v}" refs/tags/v*)
npm version "$VERSION" --no-git-tag-version VERSION="${GITHUB_REF_NAME#v}"
TAG="latest" npm version "$VERSION" --no-git-tag-version
else TAG="latest"
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7) ;;
DATE=$(date +%Y%m%d) *)
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
TAG="dev" DATE=$(date +%Y%m%d)
fi npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
TAG="dev"
;;
esac
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
npm publish --tag "$TAG" npm publish --tag "$TAG"

View File

@@ -10,6 +10,7 @@
- Page-level attention banner → **Alert** - Page-level attention banner → **Alert**
- Temporary non-blocking feedback → **Toast** (via `useToast`) - Temporary non-blocking feedback → **Toast** (via `useToast`)
- Destructive action confirmation → **AlertDialog** - Destructive action confirmation → **AlertDialog**
- Destructive action needing typed confirmation → **ConfirmDialog**
- Generic dialog with custom content → **Modal** - Generic dialog with custom content → **Modal**
### "I need a form input" ### "I need a form input"
@@ -19,6 +20,8 @@
- Yes/no with label → **Checkbox** - Yes/no with label → **Checkbox**
- One of N options (≤5) → **RadioGroup** + **RadioItem** - One of N options (≤5) → **RadioGroup** + **RadioItem**
- One of N options (>5) → **Select** - One of N options (>5) → **Select**
- Select multiple from a list → **MultiSelect**
- Edit text inline without a form → **InlineEdit**
- Date/time → **DateTimePicker** - Date/time → **DateTimePicker**
- Date range → **DateRangePicker** - Date range → **DateRangePicker**
- Wrap any input with label/error/hint → **FormField** - Wrap any input with label/error/hint → **FormField**
@@ -52,12 +55,14 @@
- Categorical comparison → **BarChart** - Categorical comparison → **BarChart**
- Inline trend → **Sparkline** - Inline trend → **Sparkline**
- Event log → **EventFeed** - Event log → **EventFeed**
- Processing pipeline → **ProcessorTimeline** - Processing pipeline (Gantt view)**ProcessorTimeline**
- Processing pipeline (flow diagram) → **RouteFlow**
### "I need to organize content" ### "I need to organize content"
- Collapsible sections (standalone) → **Collapsible** - Collapsible sections (standalone) → **Collapsible**
- Multiple collapsible sections (one/many open) → **Accordion** - Multiple collapsible sections (one/many open) → **Accordion**
- Tabbed content → **Tabs** - Tabbed content → **Tabs**
- Tab switching with pill/segment style → **SegmentedTabs**
- Side panel inspector → **DetailPanel** - Side panel inspector → **DetailPanel**
- Section with title + action → **SectionHeader** - Section with title + action → **SectionHeader**
- Empty content placeholder → **EmptyState** - Empty content placeholder → **EmptyState**
@@ -73,12 +78,15 @@
- Single user avatar → **Avatar** - Single user avatar → **Avatar**
- Stacked user avatars → **AvatarGroup** - Stacked user avatars → **AvatarGroup**
### "I need to group buttons" ### "I need to group buttons or toggle selections"
- Connected button strip (toggle group, segmented control)**ButtonGroup** (horizontal or vertical) - Multi-select toggle group with colored indicators**ButtonGroup** (e.g., status filters)
- Tab switching with pill/segment style → **SegmentedTabs**
### "I need filtering" ### "I need filtering"
- Filter pill/chip → **FilterPill** - Multi-select status/category filter → **ButtonGroup** (toggle items on/off)
- Filter pill/chip → **FilterPill** (individual toggleable pills)
- Full filter bar with search → **FilterBar** - Full filter bar with search → **FilterBar**
- Select multiple from a list → **MultiSelect**
## Composition Patterns ## Composition Patterns
@@ -109,9 +117,11 @@ Below: charts (AreaChart, LineChart, BarChart)
### Detail/inspector pattern ### Detail/inspector pattern
``` ```
DetailPanel (right slide) with Tabs for sections DetailPanel (right slide) with Tabs for sections OR children for scrollable content
Each tab: Cards with data, CodeBlock for payloads, Tabbed: use tabs prop for multiple panels
ProcessorTimeline for exchange flow Scrollable: use children for stacked sections (overview, errors, route flow, timeline)
Each section: Cards with data, CodeBlock for payloads,
ProcessorTimeline or RouteFlow for exchange flow
``` ```
### Feedback flow ### Feedback flow
@@ -151,16 +161,17 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| BarChart | composite | Categorical data comparison, optional stacking | | BarChart | composite | Categorical data comparison, optional stacking |
| Breadcrumb | composite | Navigation path showing current location | | Breadcrumb | composite | Navigation path showing current location |
| Button | primitive | Action trigger (primary, secondary, danger, ghost) | | Button | primitive | Action trigger (primary, secondary, danger, ghost) |
| ButtonGroup | primitive | Groups buttons into a connected strip with shared borders (horizontal/vertical) | | ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
| Card | primitive | Content container with optional accent border | | Card | primitive | Content container with optional accent border |
| Checkbox | primitive | Boolean input with label | | Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display | | CodeBlock | primitive | Syntax-highlighted code/JSON display |
| Collapsible | primitive | Single expand/collapse section | | Collapsible | primitive | Single expand/collapse section |
| CommandPalette | composite | Full-screen search and command interface | | CommandPalette | composite | Full-screen search and command interface |
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius | | DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
| DateRangePicker | primitive | Date range selection with presets | | DateRangePicker | primitive | Date range selection with presets |
| DateTimePicker | primitive | Single date/time input | | DateTimePicker | primitive | Single date/time input |
| DetailPanel | composite | Slide-in side panel with tabs | | DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
| Dropdown | composite | Action menu triggered by any element | | Dropdown | composite | Action menu triggered by any element |
| EmptyState | primitive | Placeholder for empty content areas | | EmptyState | primitive | Placeholder for empty content areas |
| EventFeed | composite | Chronological event log with severity | | EventFeed | composite | Chronological event log with severity |
@@ -169,20 +180,24 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| FilterPill | primitive | Individual filter chip (active/inactive), supports forwardRef | | FilterPill | primitive | Individual filter chip (active/inactive), supports forwardRef |
| FormField | primitive | Wrapper adding label, hint, error to any input | | FormField | primitive | Wrapper adding label, hint, error to any input |
| InfoCallout | primitive | Inline contextual note with variant colors | | InfoCallout | primitive | Inline contextual note with variant colors |
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
| Input | primitive | Single-line text input with optional icon | | Input | primitive | Single-line text input with optional icon |
| KeyboardHint | primitive | Keyboard shortcut display | | KeyboardHint | primitive | Keyboard shortcut display |
| Label | primitive | Form label with optional required asterisk | | Label | primitive | Form label with optional required asterisk |
| LineChart | composite | Time series line visualization | | LineChart | composite | Time series line visualization |
| MenuItem | composite | Sidebar navigation item with health/count | | MenuItem | composite | Sidebar navigation item with health/count |
| Modal | composite | Generic dialog overlay with backdrop | | Modal | composite | Generic dialog overlay with backdrop |
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
| MonoText | primitive | Inline monospace text (xs, sm, md) | | MonoText | primitive | Inline monospace text (xs, sm, md) |
| Pagination | primitive | Page navigation controls | | Pagination | primitive | Page navigation controls |
| Popover | composite | Click-triggered floating panel with arrow | | Popover | composite | Click-triggered floating panel with arrow |
| ProcessorTimeline | composite | Pipeline exchange visualization | | 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? |
| ProgressBar | primitive | Determinate/indeterminate progress indicator | | ProgressBar | primitive | Determinate/indeterminate progress indicator |
| RadioGroup | primitive | Single-select option group (use with RadioItem) | | RadioGroup | primitive | Single-select option group (use with RadioItem) |
| RadioItem | primitive | Individual radio option within RadioGroup | | RadioItem | primitive | Individual radio option within RadioGroup |
| SectionHeader | primitive | Section title with optional action button | | SectionHeader | primitive | Section title with optional action button |
| SegmentedTabs | composite | Pill-style segmented tab bar with sliding animated indicator. Same API as Tabs but with elevated active state. Props: tabs, active, onChange, trailing, trailingValue, className |
| Select | primitive | Dropdown select input | | Select | primitive | Dropdown select input |
| ShortcutsBar | composite | Keyboard shortcuts reference bar | | ShortcutsBar | composite | Keyboard shortcuts reference bar |
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) | | Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
@@ -203,8 +218,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Component | Purpose | | Component | Purpose |
|-----------|---------| |-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel | | AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | Hierarchical navigation with Applications/Agents trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) | | 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, environment, user info | | TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
## Import Paths ## Import Paths

111
README.md Normal file
View File

@@ -0,0 +1,111 @@
# Cameleer3 Design System
A component library and interactive UI prototype for the Cameleer3 monitoring platform. This project contains both the reusable design system (primitives, composites, layout components) and a fully functional mock application demonstrating all pages and interactions.
## Prerequisites
- **Node.js** 20 or later (tested with 22.x) — [https://nodejs.org](https://nodejs.org)
- **Git** — [https://git-scm.com](https://git-scm.com)
No other tools, accounts, or access to external registries are required. All dependencies are published on the public npm registry.
## Getting Started
```bash
# 1. Clone the repository
git clone <repo-url> cameleer-design-system
cd cameleer-design-system
# 2. Install dependencies
npm install
# 3. Start the development server
npm run dev
```
The dev server will start at **http://localhost:5173** (Vite will print the exact URL).
## Available Scripts
| Command | Description |
|---------|-------------|
| `npm run dev` | Start the development server with hot reload |
| `npm run build` | Type-check and build the production bundle |
| `npm run preview` | Serve the production build locally |
| `npm test` | Run the test suite (Vitest, 332 tests) |
| `npm run lint` | Run ESLint |
## Navigating the Prototype
Once the dev server is running, open **http://localhost:5173** in your browser. The application includes these sections:
### Sidebar Navigation
- **Applications** — Exchange monitoring dashboard
- `/apps` — All exchanges across all applications
- `/apps/:appId` — Filtered by application
- `/apps/:appId/:routeId` — Filtered by application and route
- Click the ↗ icon on any row to open the full **Exchange Detail** page
- **Agents** — JVM agent health monitoring
- `/agents` — Overview of all agent instances grouped by application
- `/agents/:appId` — Single application's agents
- `/agents/:appId/:instanceId` — Instance detail with CPU, memory, threads, GC charts
- **Routes** — Per-route performance metrics
- `/routes` — Aggregated KPI cards, route performance table, charts
- `/routes/:appId` — Filtered by application
- `/routes/:appId/:routeId` — Per-processor statistics and route flow diagram
- **Admin** — User management (RBAC), OIDC configuration, audit log
- `/admin/rbac` — Users, groups, roles with inline editing
- `/admin/oidc` — OIDC provider configuration form
- `/admin/audit` — Searchable audit log table
- **Inventory** — Component showcase
- `/inventory` — Interactive demos of every design system component
### Top Bar Controls
- **Search** (Ctrl+K) — Full-text search across applications, routes, agents, exchanges
- **Status Filters** — Toggle OK / Warn / Error / Running to filter exchanges
- **Time Range** — Preset time ranges (1h, 3h, 6h, Today, 24h, 7d) or custom date/time picker
- **Theme Toggle** (☾/☀) — Switch between light and dark mode
### Key Interactions
- **Exchange slide-in panel** — Click any row in the exchanges table to open a detail panel on the right showing overview, errors, route flow, and processor timeline
- **Exchange detail page** — Click the ↗ icon or "Open full details" link for the full inspector with Message IN/OUT panels and correlation chain
- **Processor selection** — On the exchange detail page, click any processor in the timeline or flow diagram to see its message snapshots
- **Starring** — Hover any item in the sidebar trees and click the star to pin it to the Starred section
- **Dark mode** — Click the moon/sun icon in the top bar to toggle themes
## Project Structure
```
src/
design-system/
primitives/ # Atomic components (Button, Input, Badge, StatusDot, ...)
composites/ # Composed components (DataTable, Modal, EventFeed, RouteFlow, ...)
layout/ # Page-level layout (AppShell, Sidebar, TopBar)
providers/ # React context providers (Theme, CommandPalette, GlobalFilter)
tokens.css # Design tokens (colors, spacing, typography, shadows)
utils/ # Shared utilities (hashColor, timePresets)
pages/ # Application pages (Dashboard, Routes, AgentHealth, Admin, ...)
mocks/ # Static mock data (exchanges, routes, agents, metrics, sidebar)
```
## Tech Stack
- **React 19** + **TypeScript**
- **Vite** for development and bundling
- **CSS Modules** for styling (all colors via design tokens)
- **Vitest** + **React Testing Library** for tests
- No runtime CSS-in-JS, no Tailwind, no external component libraries
## Notes
- All data is static mock data — no backend or API calls required
- The prototype is fully self-contained and works offline after `npm install`
- Light and dark themes are supported throughout
- Fonts (DM Sans, JetBrains Mono) are loaded from Google Fonts and require an internet connection on first load

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,950 @@
# Admin Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Overhaul admin section UX/UI to match the design language of the rest of the application, fix critical bugs, and improve usability.
**Architecture:** Mostly file edits — replacing custom implementations with design system composites (DataTable, Tabs), fixing tokens, reworking the user creation flow with provider awareness, and adding toast feedback + accessibility.
**Tech Stack:** React 18, TypeScript, CSS Modules, Vitest + RTL
**Spec:** `docs/superpowers/specs/2026-03-18-admin-redesign.md`
---
## File Map
### Modified files
```
src/pages/Admin/Admin.tsx — Replace custom nav with Tabs, import Tabs
src/pages/Admin/Admin.module.css — Remove nav styles, fix padding
src/pages/Admin/AuditLog/AuditLog.tsx — Rewrite to use DataTable
src/pages/Admin/AuditLog/AuditLog.module.css — Replace with card + filter styles only
src/pages/Admin/AuditLog/auditMocks.ts — Change id to string
src/pages/Admin/OidcConfig/OidcConfig.tsx — Remove h2 title, add toolbar
src/pages/Admin/OidcConfig/OidcConfig.module.css — Remove header styles, center form
src/pages/Admin/UserManagement/UserManagement.tsx — Replace inline style
src/pages/Admin/UserManagement/UserManagement.module.css — Fix tokens, radii, shadow, add tabContent + empty/security styles
src/pages/Admin/UserManagement/UsersTab.tsx — Rework create form, add security section, toasts, accessibility, confirmations
src/pages/Admin/UserManagement/GroupsTab.tsx — Add toasts, accessibility, confirmations, empty state
src/pages/Admin/UserManagement/RolesTab.tsx — Replace emoji, add toasts, accessibility, empty state
src/pages/Admin/UserManagement/rbacMocks.ts — No changes needed (provider field already exists)
```
---
### Task 1: Fix critical token bug + visual polish
**Files:**
- Modify: `src/pages/Admin/Admin.module.css`
- Modify: `src/pages/Admin/UserManagement/UserManagement.module.css`
- [ ] **Step 1: Fix `--bg-base` token in Admin.module.css**
In `Admin.module.css`, replace `var(--bg-base)` with `var(--bg-surface)` on line 6.
Also fix the content padding on line 35: change `padding: 20px` to `padding: 20px 24px 40px`.
- [ ] **Step 2: Fix `--bg-base` token and visual polish in UserManagement.module.css**
Replace both `var(--bg-base)` occurrences (lines 12, 19) with `var(--bg-surface)`.
Change `border-radius: var(--radius-md)` to `var(--radius-lg)` on `.splitPane` (line 7), `.listPane` (line 15), and `.detailPane` (line 22).
Add `box-shadow: var(--shadow-card)` to `.splitPane`.
Add these new classes at the end of the file:
```css
.tabContent {
margin-top: 16px;
}
.emptySearch {
padding: 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
.securitySection {
margin-top: 8px;
margin-bottom: 8px;
}
.securityRow {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
font-family: var(--font-body);
color: var(--text-primary);
}
.passwordDots {
font-family: var(--font-mono);
letter-spacing: 2px;
}
.resetForm {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.resetInput {
width: 200px;
}
```
- [ ] **Step 3: Commit**
```bash
git add src/pages/Admin/Admin.module.css src/pages/Admin/UserManagement/UserManagement.module.css
git commit -m "fix: replace nonexistent --bg-base token with --bg-surface, fix radii and padding"
```
---
### Task 2: Replace admin nav with Tabs composite
**Files:**
- Modify: `src/pages/Admin/Admin.tsx`
- Modify: `src/pages/Admin/Admin.module.css`
- [ ] **Step 1: Rewrite Admin.tsx**
Replace the entire file with:
```tsx
import { useNavigate, useLocation } from 'react-router-dom'
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
import styles from './Admin.module.css'
import type { ReactNode } from 'react'
const ADMIN_TABS = [
{ label: 'User Management', value: '/admin/rbac' },
{ label: 'Audit Log', value: '/admin/audit' },
{ label: 'OIDC', value: '/admin/oidc' },
]
interface AdminLayoutProps {
title: string
children: ReactNode
}
export function AdminLayout({ title, children }: AdminLayoutProps) {
const navigate = useNavigate()
const location = useLocation()
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[
{ label: 'Admin', href: '/admin' },
{ label: title },
]}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<Tabs
tabs={ADMIN_TABS}
active={location.pathname}
onChange={(path) => navigate(path)}
/>
<div className={styles.adminContent}>
{children}
</div>
</AppShell>
)
}
```
- [ ] **Step 2: Clean up Admin.module.css**
Remove `.adminNav`, `.adminTab`, `.adminTab:hover`, `.adminTabActive` styles entirely. Keep only `.adminContent`.
- [ ] **Step 3: Commit**
```bash
git add src/pages/Admin/Admin.tsx src/pages/Admin/Admin.module.css
git commit -m "refactor: replace custom admin nav with Tabs composite"
```
---
### Task 3: Fix AuditLog mock data + migrate to DataTable
**Files:**
- Modify: `src/pages/Admin/AuditLog/auditMocks.ts`
- Modify: `src/pages/Admin/AuditLog/AuditLog.tsx`
- Modify: `src/pages/Admin/AuditLog/AuditLog.module.css`
- [ ] **Step 1: Change AuditEvent id to string in auditMocks.ts**
Change `id: number` to `id: string` in the `AuditEvent` interface.
Change all mock IDs from numbers to strings: `id: 1``id: 'audit-1'`, `id: 2``id: 'audit-2'`, etc. through all 25 events.
- [ ] **Step 2: Rewrite AuditLog.tsx to use DataTable**
Replace the entire file with:
```tsx
import { useState, useMemo } from 'react'
import { AdminLayout } from '../Admin'
import { Badge } from '../../../design-system/primitives/Badge/Badge'
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
import { Input } from '../../../design-system/primitives/Input/Input'
import { Select } from '../../../design-system/primitives/Select/Select'
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
import { DataTable } from '../../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../../design-system/composites/DataTable/types'
import type { DateRange } from '../../../design-system/utils/timePresets'
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
import styles from './AuditLog.module.css'
const CATEGORIES = [
{ value: '', label: 'All categories' },
{ value: 'INFRA', label: 'INFRA' },
{ value: 'AUTH', label: 'AUTH' },
{ value: 'USER_MGMT', label: 'USER_MGMT' },
{ value: 'CONFIG', label: 'CONFIG' },
]
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
})
}
const COLUMNS: Column<AuditEvent>[] = [
{
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
},
{
key: 'username', header: 'User', sortable: true,
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
},
{
key: 'category', header: 'Category', width: '110px', sortable: true,
render: (_, row) => <Badge label={row.category} color="auto" />,
},
{ key: 'action', header: 'Action' },
{
key: 'target', header: 'Target',
render: (_, row) => <span className={styles.target}>{row.target}</span>,
},
{
key: 'result', header: 'Result', width: '90px', sortable: true,
render: (_, row) => (
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
),
},
]
const now = Date.now()
const INITIAL_RANGE: DateRange = {
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
to: new Date(now).toISOString().slice(0, 16),
}
export function AuditLog() {
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
const [userFilter, setUserFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [searchFilter, setSearchFilter] = useState('')
const filtered = useMemo(() => {
const from = new Date(dateRange.from).getTime()
const to = new Date(dateRange.to).getTime()
return AUDIT_EVENTS.filter((e) => {
const ts = new Date(e.timestamp).getTime()
if (ts < from || ts > to) return false
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
if (categoryFilter && e.category !== categoryFilter) return false
if (searchFilter) {
const q = searchFilter.toLowerCase()
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
}
return true
})
}, [dateRange, userFilter, categoryFilter, searchFilter])
return (
<AdminLayout title="Audit Log">
<div className={styles.filters}>
<DateRangePicker
value={dateRange}
onChange={setDateRange}
/>
<Input
placeholder="Filter by user..."
value={userFilter}
onChange={(e) => setUserFilter(e.target.value)}
onClear={() => setUserFilter('')}
className={styles.filterInput}
/>
<Select
options={CATEGORIES}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className={styles.filterSelect}
/>
<Input
placeholder="Search action or target..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
onClear={() => setSearchFilter('')}
className={styles.filterInput}
/>
</div>
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Audit Log</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{filtered.length} events
</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={COLUMNS}
data={filtered}
sortable
flush
pageSize={10}
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
expandedContent={(row) => (
<div className={styles.expandedDetail}>
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<MonoText size="xs">{row.ipAddress}</MonoText>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{row.userAgent}</span>
</div>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Detail</span>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
</div>
</div>
)}
/>
</div>
</AdminLayout>
)
}
```
- [ ] **Step 3: Rewrite AuditLog.module.css**
Replace the entire file with:
```css
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filterInput {
width: 200px;
}
.filterSelect {
width: 160px;
}
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
}
.target {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expandedDetail {
padding: 4px 0;
}
.detailGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.detailField {
display: flex;
flex-direction: column;
gap: 4px;
}
.detailLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
font-family: var(--font-body);
}
.detailValue {
font-size: 12px;
color: var(--text-secondary);
}
```
- [ ] **Step 4: Verify build**
Run: `npx vite build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 5: Commit**
```bash
git add src/pages/Admin/AuditLog/
git commit -m "refactor: migrate AuditLog to DataTable with card wrapper"
```
---
### Task 4: Remove duplicate titles from OidcConfig
**Files:**
- Modify: `src/pages/Admin/OidcConfig/OidcConfig.tsx`
- Modify: `src/pages/Admin/OidcConfig/OidcConfig.module.css`
- [ ] **Step 1: Replace h2 header with toolbar in OidcConfig.tsx**
Replace the `.header` div (lines 74-84) with a compact toolbar:
```tsx
<div className={styles.toolbar}>
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
Test Connection
</Button>
<Button size="sm" variant="primary" onClick={handleSave}>
Save
</Button>
</div>
```
- [ ] **Step 2: Update OidcConfig.module.css**
Remove `.header`, `.title`, `.headerActions`. Add:
```css
.toolbar {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-bottom: 20px;
}
```
Also add `margin: 0 auto` to the `.page` class to center the form.
- [ ] **Step 3: Commit**
```bash
git add src/pages/Admin/OidcConfig/
git commit -m "refactor: remove duplicate title from OIDC page, center form"
```
---
### Task 5: Replace inline style + fix UserManagement
**Files:**
- Modify: `src/pages/Admin/UserManagement/UserManagement.tsx`
- [ ] **Step 1: Replace inline style with CSS class**
Change line 20 from `<div style={{ marginTop: 16 }}>` to `<div className={styles.tabContent}>`.
Add the `styles` import if not already present (it already imports from `./UserManagement.module.css` — wait, it doesn't currently. Add:
```tsx
import styles from './UserManagement.module.css'
```
- [ ] **Step 2: Commit**
```bash
git add src/pages/Admin/UserManagement/UserManagement.tsx
git commit -m "refactor: replace inline style with CSS module class"
```
---
### Task 6: Add toasts to all RBAC tabs
**Files:**
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
- [ ] **Step 1: Add useToast to UsersTab**
Add import: `import { useToast } from '../../../design-system/composites/Toast/Toast'`
Add `const { toast } = useToast()` at the top of the `UsersTab` function body.
Add toast calls:
- After `setSelectedId(newUser.id)` in `handleCreate`: `toast({ title: 'User created', description: newUser.displayName, variant: 'success' })`
- After `setDeleteTarget(null)` in `handleDelete`: `toast({ title: 'User deleted', description: deleteTarget.username, variant: 'warning' })`
- [ ] **Step 2: Add useToast to GroupsTab**
Same pattern. Add toast calls:
- After create: `toast({ title: 'Group created', description: newGroup.name, variant: 'success' })`
- After delete: `toast({ title: 'Group deleted', description: deleteTarget.name, variant: 'warning' })`
- [ ] **Step 3: Add useToast to RolesTab**
Same pattern. Add toast calls:
- After create: `toast({ title: 'Role created', description: newRole.name, variant: 'success' })`
- After delete: `toast({ title: 'Role deleted', description: deleteTarget.name, variant: 'warning' })`
- [ ] **Step 4: Commit**
```bash
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
git commit -m "feat: add toast notifications to all RBAC mutations"
```
---
### Task 7: Rework user creation form + password management
**Files:**
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
This is the largest single task. It covers spec items 3.2 (provider-aware create form), 3.3 (password management in detail pane), and 3.6 (remove unused password field).
- [ ] **Step 1: Rework the create form section**
Replace the create form state variables (lines 23-26):
```tsx
const [newUsername, setNewUsername] = useState('')
const [newDisplay, setNewDisplay] = useState('')
const [newEmail, setNewEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
```
with:
```tsx
const [newUsername, setNewUsername] = useState('')
const [newDisplay, setNewDisplay] = useState('')
const [newEmail, setNewEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local')
```
Add imports for RadioGroup, RadioItem, and InfoCallout:
```tsx
import { RadioGroup, RadioItem } from '../../../design-system/primitives/Radio/Radio'
import { InfoCallout } from '../../../design-system/primitives/InfoCallout/InfoCallout'
```
Update `handleCreate` to use the provider selection and validate password for local:
```tsx
function handleCreate() {
if (!newUsername.trim()) return
if (newProvider === 'local' && !newPassword.trim()) return
const newUser: MockUser = {
id: `usr-${Date.now()}`,
username: newUsername.trim(),
displayName: newDisplay.trim() || newUsername.trim(),
email: newEmail.trim(),
provider: newProvider,
createdAt: new Date().toISOString(),
directRoles: [],
directGroups: [],
}
setUsers((prev) => [...prev, newUser])
setCreating(false)
setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword(''); setNewProvider('local')
setSelectedId(newUser.id)
toast({ title: 'User created', description: newUser.displayName, variant: 'success' })
}
```
Replace the create form JSX (lines 100-114) with:
```tsx
{creating && (
<div className={styles.createForm}>
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
<RadioItem value="local" label="Local" />
<RadioItem value="oidc" label="OIDC" />
</RadioGroup>
<div className={styles.createFormRow}>
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
</div>
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
{newProvider === 'local' && (
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
)}
{newProvider === 'oidc' && (
<InfoCallout variant="amber">
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
</InfoCallout>
)}
<div className={styles.createFormActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
disabled={!newUsername.trim() || (newProvider === 'local' && !newPassword.trim())}
>
Create
</Button>
</div>
</div>
)}
```
- [ ] **Step 2: Add Security section to detail pane**
Add password reset state at the top of the component:
```tsx
const [resettingPassword, setResettingPassword] = useState(false)
const [newPw, setNewPw] = useState('')
```
Add the Security section after the metadata grid (after the `</div>` closing the `.metaGrid`), before the "Group membership" SectionHeader:
```tsx
<SectionHeader>Security</SectionHeader>
<div className={styles.securitySection}>
{selected.provider === 'local' ? (
<>
<div className={styles.securityRow}>
<span className={styles.metaLabel}>Password</span>
<span className={styles.passwordDots}></span>
{!resettingPassword && (
<Button size="sm" variant="ghost" onClick={() => { setResettingPassword(true); setNewPw('') }}>
Reset password
</Button>
)}
</div>
{resettingPassword && (
<div className={styles.resetForm}>
<Input
placeholder="New password"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
className={styles.resetInput}
/>
<Button size="sm" variant="ghost" onClick={() => setResettingPassword(false)}>Cancel</Button>
<Button
size="sm"
variant="primary"
onClick={() => { setResettingPassword(false); toast({ title: 'Password updated', description: selected.username, variant: 'success' }) }}
disabled={!newPw.trim()}
>
Set
</Button>
</div>
)}
</>
) : (
<>
<div className={styles.securityRow}>
<span className={styles.metaLabel}>Authentication</span>
<span className={styles.metaValue}>OIDC ({selected.provider})</span>
</div>
<InfoCallout variant="amber">
Password managed by the identity provider.
</InfoCallout>
</>
)}
</div>
```
- [ ] **Step 3: Verify build**
Run: `npx vite build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 4: Commit**
```bash
git add src/pages/Admin/UserManagement/UsersTab.tsx
git commit -m "feat: rework user creation with provider selection, add password management"
```
---
### Task 8: Keyboard accessibility for entity lists
**Files:**
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
- [ ] **Step 1: Add keyboard support to UsersTab entity list**
On the `.entityList` wrapper div, add:
```tsx
<div className={styles.entityList} role="listbox" aria-label="Users">
```
On each `.entityItem` div, add `role`, `tabIndex`, `aria-selected`, and `onKeyDown`:
```tsx
<div
key={user.id}
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(user.id)}
role="option"
tabIndex={0}
aria-selected={selectedId === user.id}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(user.id) } }}
>
```
Add empty-search state after the list map:
```tsx
{filtered.length === 0 && (
<div className={styles.emptySearch}>No users match your search</div>
)}
```
- [ ] **Step 2: Add keyboard support to GroupsTab entity list**
Same pattern — add `role="listbox"` to container, `role="option"` + `tabIndex={0}` + `aria-selected` + `onKeyDown` to items, and empty-search state.
- [ ] **Step 3: Add keyboard support to RolesTab entity list**
Same pattern. Also replace the lock emoji on line 113:
```tsx
{role.system && <span title="System role"> 🔒</span>}
```
with:
```tsx
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
```
Add empty-search state.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
git commit -m "feat: add keyboard accessibility and empty states to entity lists"
```
---
### Task 9: Add confirmation for cascading removals
**Files:**
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
- [ ] **Step 1: Add group removal confirmation in UsersTab**
Add state for tracking the removal target:
```tsx
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null)
```
Replace the direct `onRemove` on group Tags (in the "Group membership" section) with:
```tsx
onRemove={() => {
const group = MOCK_GROUPS.find((gr) => gr.id === gId)
if (group && group.directRoles.length > 0) {
setRemoveGroupTarget(gId)
} else {
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== gId) })
toast({ title: 'Group removed', variant: 'success' })
}
}}
```
Add an AlertDialog (import from composites) for the confirmation:
```tsx
<AlertDialog
open={removeGroupTarget !== null}
onClose={() => setRemoveGroupTarget(null)}
onConfirm={() => {
if (removeGroupTarget && selected) {
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== removeGroupTarget) })
toast({ title: 'Group removed', variant: 'success' })
}
setRemoveGroupTarget(null)
}}
title="Remove group membership"
description={`Removing this group will also revoke inherited roles: ${MOCK_GROUPS.find((g) => g.id === removeGroupTarget)?.directRoles.join(', ') ?? ''}. Continue?`}
confirmLabel="Remove"
variant="warning"
/>
```
Add import: `import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'`
- [ ] **Step 2: Add role removal confirmation in GroupsTab**
Add state:
```tsx
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null)
```
Replace direct `onRemove` on role Tags with:
```tsx
onRemove={() => {
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
if (memberCount > 0) {
setRemoveRoleTarget(r)
} else {
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
toast({ title: 'Role removed', variant: 'success' })
}
}}
```
Add AlertDialog:
```tsx
<AlertDialog
open={removeRoleTarget !== null}
onClose={() => setRemoveRoleTarget(null)}
onConfirm={() => {
if (removeRoleTarget && selected) {
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== removeRoleTarget) })
toast({ title: 'Role removed', variant: 'success' })
}
setRemoveRoleTarget(null)
}}
title="Remove role from group"
description={`Removing ${removeRoleTarget} from ${selected?.name} will affect ${members.length} member(s) who inherit this role. Continue?`}
confirmLabel="Remove"
variant="warning"
/>
```
Add import: `import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'`
- [ ] **Step 3: Commit**
```bash
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx
git commit -m "feat: add confirmation dialogs for cascading removals"
```
---
### Task 10: Add duplicate name validation to create forms
**Files:**
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
- [ ] **Step 1: Add duplicate check in UsersTab**
Add a computed `duplicateUsername`:
```tsx
const duplicateUsername = newUsername.trim() && users.some((u) => u.username.toLowerCase() === newUsername.trim().toLowerCase())
```
Update the Create button `disabled` to include `|| duplicateUsername`.
Show error text below the username Input when duplicate:
```tsx
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
```
- [ ] **Step 2: Add duplicate check in GroupsTab**
Similar pattern with `duplicateGroupName` check. Disable Create button when duplicate.
- [ ] **Step 3: Add duplicate check in RolesTab**
Similar pattern with `duplicateRoleName` check (compare uppercase). Disable Create button when duplicate.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
git commit -m "feat: add duplicate name validation to create forms"
```
---
### Task 11: Final verification
- [ ] **Step 1: Run full test suite**
Run: `npx vitest run`
Expected: All tests pass
- [ ] **Step 2: Build the project**
Run: `npx vite build`
Expected: Build succeeds
- [ ] **Step 3: Fix any issues**
If build fails, fix TypeScript errors. Common issues:
- Import path typos
- Missing props on components
- InfoCallout `variant` prop — check the actual prop name (may be `color` instead)
- [ ] **Step 4: Commit fixes if needed**
```bash
git add -A
git commit -m "fix: resolve build issues from admin redesign"
```

View File

@@ -0,0 +1,249 @@
# Admin Pages + New Components Design
**Date:** 2026-03-18
**Scope:** 3 new design system components + 3 admin example pages
## Overview
Transfer the admin section from cameleer3-server UI to the design system project as example pages. Add three new reusable components to the design system that are needed by these pages and useful generally.
## New Design System Components
### 1. MultiSelect (composite)
Dropdown trigger that opens a positioned panel with searchable checkbox list and "Apply" action.
**Props:**
```typescript
interface MultiSelectOption {
value: string
label: string
}
interface MultiSelectProps {
options: MultiSelectOption[]
value: string[]
onChange: (value: string[]) => void
placeholder?: string // default: "Select..."
searchable?: boolean // default: true
disabled?: boolean
className?: string
}
```
**Behavior:**
- Click trigger → panel opens below with search input + checkbox list + "Apply (N)" footer
- Search filters options by label (case-insensitive)
- Checkboxes toggle selection; changes are local until "Apply" is clicked
- Apply calls `onChange` with selected values and closes panel
- Click outside or Escape closes without applying (discards pending changes)
- Trigger shows count: "2 selected" or placeholder when empty
- Arrow keys navigate checkbox list, Space toggles focused item, Tab moves between search/list/apply
- Panel has max-height with scroll for long option lists
**Accessibility:**
- Trigger: `role="combobox"`, `aria-expanded`, `aria-haspopup="listbox"`
- Option list: `role="listbox"`, options have `role="option"` with `aria-selected`
- Search input: `aria-label="Filter options"`
**Implementation:**
- New directory: `src/design-system/composites/MultiSelect/`
- Manages its own open/close state and positioning (does NOT wrap Popover — needs controlled close behavior to distinguish apply vs. discard)
- Uses portal for the dropdown panel to avoid overflow clipping
- CSS Modules with design tokens
### 2. ConfirmDialog (composite)
Modal dialog requiring the user to type a confirmation string before a destructive action proceeds.
**Props:**
```typescript
interface ConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
title?: string // default: "Confirm Deletion"
message: string // e.g., "Delete user 'alice'? This cannot be undone."
confirmText: string // text the user must type to enable confirm button (must be non-empty)
confirmLabel?: string // default: "Delete"
cancelLabel?: string // default: "Cancel"
variant?: 'danger' | 'warning' | 'info' // default: 'danger'
loading?: boolean // default: false — disables buttons, shows pending state
className?: string
}
```
**Behavior:**
- Built on Modal (size="sm")
- Shows title, message, text input with label "Type '{confirmText}' to confirm"
- Confirm button disabled until input matches `confirmText` exactly
- Input clears on open
- Enter submits when enabled; Escape closes
- Confirm button uses danger/warning/info variant styling (matches AlertDialog pattern)
- When `loading` is true, both buttons are disabled
**Implementation:**
- New directory: `src/design-system/composites/ConfirmDialog/`
- Reuses Modal internally (same pattern as AlertDialog)
- Auto-focuses input on open
### 3. InlineEdit (primitive)
Click-to-edit text field that toggles between display and edit modes.
**Props:**
```typescript
interface InlineEditProps {
value: string
onSave: (value: string) => void
placeholder?: string // shown when value is empty in display mode
disabled?: boolean
className?: string
}
```
**Behavior:**
- **Display mode:** Shows value as text with subtle edit icon (pencil). Clicking text or icon enters edit mode.
- **Edit mode:** Input field with current value. Enter saves. Escape cancels (reverts to original value). Blur cancels (same as Escape — prevents accidental saves when clicking away).
- If saved value is empty and placeholder exists, display mode shows placeholder in muted style.
- No save/cancel buttons — Enter saves, Escape/blur cancels (lightweight inline pattern).
**Implementation:**
- New directory: `src/design-system/primitives/InlineEdit/`
- No forwardRef — the component manages its own input internally (the input only exists in edit mode, so a forwarded ref would be null in display mode)
- Manages internal editing state with useState
## Admin Pages
### Route Structure
```
/admin → redirects to /admin/rbac
/admin/audit → AuditLog page
/admin/oidc → OidcConfig page
/admin/rbac → UserManagement page (tabs: Users | Groups | Roles)
```
All pages use the standard AppShell + Sidebar + TopBar layout with breadcrumbs.
### Router Integration
Update `App.tsx` to replace the single `/admin` route with nested routes:
```tsx
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
<Route path="/admin/audit" element={<AuditLog />} />
<Route path="/admin/oidc" element={<OidcConfig />} />
<Route path="/admin/rbac" element={<UserManagement />} />
```
### Sidebar Integration
Keep the existing single "Admin" bottom link in the Sidebar. The admin pages handle their own sub-navigation internally via a secondary nav bar at the top of each admin page (links to Audit Log, OIDC, User Management). This avoids cluttering the main sidebar with admin sub-entries.
### Barrel Export Updates
- Add `MultiSelect` and `MultiSelectOption` type to `src/design-system/composites/index.ts`
- Add `ConfirmDialog` and `ConfirmDialogProps` type to `src/design-system/composites/index.ts`
- Add `InlineEdit` and `InlineEditProps` type to `src/design-system/primitives/index.ts`
### Page: Audit Log (`src/pages/Admin/AuditLog/`)
**Layout:** Full-width content area (no split pane).
**Sections:**
1. **Header** — "Audit Log" title + event count badge
2. **Filter bar** — DateRangePicker (from/to), Input (user), Select (category: INFRA/AUTH/USER_MGMT/CONFIG), Input (search)
3. **Data table** — DataTable with columns: Timestamp (monospace), User, Category (Badge), Action, Target, Result (Badge with success/error color)
4. **Expandable rows** — Clicking a row reveals detail section with IP address, user agent, and JSON detail (CodeBlock)
5. **Pagination** — Pagination component below table
**Mock data:** ~30 audit events with varied categories, actions, results.
### Page: OIDC Config (`src/pages/Admin/OidcConfig/`)
**Layout:** Single-column form layout, max-width constrained.
**Sections:**
1. **Header** — "OIDC Configuration" + Save/Test/Delete buttons
2. **Behavior** — Two Toggle fields (Enabled, Auto Sign-Up) wrapped in FormField
3. **Provider Settings** — FormField-wrapped Inputs for Issuer URI, Client ID, Client Secret (type=password)
4. **Claim Mapping** — FormField-wrapped Inputs for Roles Claim, Display Name Claim with hint text
5. **Default Roles** — Tag list (removable) + Input + Button to add new roles
6. **Delete** — Button (danger) that opens ConfirmDialog
**Mock data:** Pre-filled form state with sample OIDC config.
### Page: User Management (`src/pages/Admin/UserManagement/`)
**Layout:** Tabs component at top (Users | Groups | Roles). Each tab has a CSS grid split-pane (roughly 52/48).
#### Users Tab
- **Left pane:** Input search + "Add user" Button + scrollable user list. Each item: Avatar + name + provider Badge + meta (email, group path) + Tag list (roles: amber, groups: green, inherited: dashed Badge).
- **Inline create form:** Appears at top of list when "Add user" clicked. Input fields for username, display, email, password. Cancel/Create buttons.
- **Right pane (detail):** Large Avatar + InlineEdit (display name) + email + Delete Button. Metadata fields (Status, ID as MonoText, Created). SectionHeader "Group membership" + Tag list (removable) + MultiSelect to add groups. SectionHeader "Effective roles" + Tag list (direct: solid, inherited: dashed with source label) + MultiSelect to add roles.
- **Delete:** ConfirmDialog (type username to confirm).
#### Groups Tab
- **Left pane:** Same pattern — search + "Add group" + group list. Each item: Avatar (square) + name + meta (parent, child count, member count) + role Tags.
- **Inline create form:** Name input + parent Select (top-level or existing group).
- **Right pane:** Avatar + InlineEdit (name) + hierarchy label. Metadata (ID, Parent — editable via Select). SectionHeader "Members" + Tag list. SectionHeader "Child groups" + Tag list. SectionHeader "Assigned roles" + removable Tags + MultiSelect. SectionHeader "Hierarchy" with indented tree display.
- **Delete:** ConfirmDialog.
#### Roles Tab
- **Left pane:** Search + "Add role" + role list. Each item: Avatar (square) + name + lock icon if system + meta (description, assignment count) + Tags.
- **Inline create form:** Name, Description, Scope inputs.
- **Right pane:** Avatar + role name (non-editable for system roles) + description. Metadata (ID, Scope, Type). SectionHeader "Assigned to groups" (read-only list). SectionHeader "Assigned to users (direct)" (read-only). SectionHeader "Effective principals" with inherited entries in dashed style.
- **Delete:** ConfirmDialog (only for non-system roles).
**Mock data:** ~8 users, ~4 groups (with nesting), ~6 roles (including system roles ADMIN, USER). Realistic role/group assignments with inheritance.
### Inventory Updates
Add demos for all three new components:
- **MultiSelect** → CompositesSection: Demo showing multi-select with sample options, displaying selected count
- **InlineEdit** → PrimitivesSection: Demo showing display/edit toggle with a sample name
- **ConfirmDialog** → CompositesSection: Demo with a "Delete item" button that opens the dialog
## File Structure
```
src/design-system/composites/MultiSelect/
MultiSelect.tsx
MultiSelect.module.css
MultiSelect.test.tsx
src/design-system/primitives/InlineEdit/
InlineEdit.tsx
InlineEdit.module.css
InlineEdit.test.tsx
src/design-system/composites/ConfirmDialog/
ConfirmDialog.tsx
ConfirmDialog.module.css
ConfirmDialog.test.tsx
src/pages/Admin/
AuditLog/
AuditLog.tsx
AuditLog.module.css
auditMocks.ts
OidcConfig/
OidcConfig.tsx
OidcConfig.module.css
UserManagement/
UserManagement.tsx
UserManagement.module.css
UsersTab.tsx
GroupsTab.tsx
RolesTab.tsx
rbacMocks.ts
```
## Out of Scope
- No backend API integration (static mock data with useState)
- No persistence across page refresh
- No access control / role gating in the example app
- Dashboard tab from RBAC is excluded per user request
- Split pane is page-local CSS, not a design system component

View File

@@ -0,0 +1,207 @@
# Admin Section Redesign Spec
**Date:** 2026-03-18
**Scope:** UX/UI consistency overhaul of AuditLog, OidcConfig, UserManagement admin pages
## Overview
Three expert reviews identified critical bugs, consistency gaps, and usability issues in the admin section. This spec covers all changes organized by priority tier.
---
## Tier 1: Critical Bugs
### 1.1 Replace nonexistent `--bg-base` token
`--bg-base` is referenced 3 times but does not exist in `tokens.css`. Dark mode is broken.
**Files:**
- `Admin.module.css` line 6: `.adminNav`
- `UserManagement.module.css` lines 13, 19: `.listPane`, `.detailPane`
**Fix:** Replace all `var(--bg-base)` with `var(--bg-surface)`.
### 1.2 Change AuditEvent `id` to string
`DataTable` requires `T extends { id: string }`. Current `AuditEvent.id` is `number`.
**Files:**
- `auditMocks.ts`: change `id: number` to `id: string`, update all IDs to `'audit-1'`, `'audit-2'`, etc.
- `AuditLog.tsx`: update `expandedId` state from `number | null` to `string | null`
---
## Tier 2: High-Impact Consistency
### 2.1 Replace admin nav with Tabs composite
The hand-rolled admin nav in `Admin.tsx` lacks ARIA roles and has subtle color differences from the Tabs composite.
**Fix:** Replace the custom `<nav>` block with:
```tsx
<Tabs
tabs={[
{ label: 'User Management', value: '/admin/rbac' },
{ label: 'Audit Log', value: '/admin/audit' },
{ label: 'OIDC', value: '/admin/oidc' },
]}
active={location.pathname}
onChange={(path) => navigate(path)}
/>
```
Delete `.adminNav`, `.adminTab`, `.adminTabActive` from `Admin.module.css`.
### 2.2 Remove duplicate page titles
Breadcrumb + active tab + h2 heading all show the same label. Remove the h2.
**Files:**
- `AuditLog.tsx`: remove the `.header` div with `<h2>Audit Log</h2>`. Move the event count badge into a toolbar row.
- `OidcConfig.tsx`: remove the `.header` div with `<h2>`. Keep Save/Test buttons in a compact toolbar row below the tabs.
Delete `.header`, `.title` CSS from both `AuditLog.module.css` and `OidcConfig.module.css`.
### 2.3 Migrate AuditLog to DataTable
Replace the hand-built `<table>` with the DataTable composite.
**Column definitions:**
- Timestamp: render with `MonoText`, width `'170px'`
- User: render with bold text
- Category: render with `Badge color="auto"`
- Action: plain text
- Target: render with ellipsis style
- Result: render with `Badge color={row.result === 'SUCCESS' ? 'success' : 'error'}`
**Features to enable:**
- `sortable` on Timestamp, User, Category, Result columns
- `rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}` — red left-border on failures
- `expandedContent={(row) => <detail block with IP, user agent, CodeBlock>}`
- `pageSize={10}`
- `flush` prop (table sits inside a card wrapper)
**Card wrapper:** Wrap DataTable in a section with `background: var(--bg-surface)`, `border: 1px solid var(--border-subtle)`, `border-radius: var(--radius-lg)`, `box-shadow: var(--shadow-card)`. Add a header row with title + event count badge, matching Dashboard's `.tableSection` pattern.
**Delete from AuditLog.module.css:** `.tableWrap`, `.table`, `.th`, `.row`, `.td`, `.userCell`, `.target`, `.empty`, `.detailRow`, `.detailCell`, `.detailGrid`, `.detailField`, `.detailLabel`, `.detailValue`, `.detailJson`. Also remove the separate `Pagination` import — DataTable handles pagination internally.
### 2.4 Fix content padding
`Admin.module.css` `.adminContent`: change `padding: 20px` to `padding: 20px 24px 40px`.
### 2.5 Center OIDC form
`OidcConfig.module.css` `.page`: add `margin: 0 auto` to center the 640px max-width form.
### 2.6 Replace inline style
`UserManagement.tsx` line 20: replace `style={{ marginTop: 16 }}` with a CSS class `.tabContent { margin-top: 16px; }` in `UserManagement.module.css`.
---
## Tier 3: Usability Improvements
### 3.1 Add toast notifications to RBAC mutations
Import `useToast` into `UsersTab.tsx`, `GroupsTab.tsx`, `RolesTab.tsx`. Fire toasts on:
- Create user/group/role: `variant: 'success'`
- Delete user/group/role: `variant: 'warning'`
- Role assigned/removed: `variant: 'success'`
- Group added/removed: `variant: 'success'`
### 3.2 Rework user creation form
Replace the flat inline form with a provider-aware two-step form.
**Form structure:**
1. **Provider selection** — RadioGroup with "Local" and "OIDC" options. Default: "Local".
2. **Fields (always shown):** Username (required), Display name (optional), Email (optional)
3. **Fields (Local only):** Password (required)
4. **OIDC info callout:** When OIDC selected, show an InfoCallout: "OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login."
**Components used:** RadioGroup + RadioItem (existing primitive), Input, InfoCallout (existing primitive), Button.
**Create handler:** Set `provider` based on RadioGroup selection. Only validate password when provider is 'local'.
The form should use the existing inline pattern (appears at the top of the list pane), but use a proper card-like treatment (the existing `.createForm` background is fine).
### 3.3 Add password management to user detail pane
Add a "Security" section (using `SectionHeader`) in the user detail pane, below the metadata grid.
**For local users:**
- Show "Password: ••••••••" with a "Reset password" Button
- Clicking "Reset password" reveals an inline form: Input (type=password, placeholder "New password") + Cancel/Set buttons
- Setting fires a success toast: "Password updated"
**For OIDC users:**
- Show "Authentication: OIDC ({provider})"
- Show InfoCallout: "Password managed by the identity provider."
- No password reset option
### 3.4 Add ConfirmDialog to cascading removals
When removing a group from a user (which may strip inherited roles), show a confirmation dialog if the group grants roles.
When removing a role from a group (which affects all members), show a confirmation dialog.
Direct role removal from a user does not need confirmation (low risk).
### 3.5 Make entity list items keyboard accessible
Add to each `.entityItem` div:
- `role="option"`
- `tabIndex={0}`
- `aria-selected={selectedId === item.id}`
- `onKeyDown`: Enter/Space to select, ArrowUp/ArrowDown to navigate
Add `role="listbox"` and `aria-label` to each `.entityList` container.
### 3.6 Add expand/collapse affordance to AuditLog
After DataTable migration (2.3), add a first column with a chevron indicator (`>` / `v`) that rotates when the row is expanded. Width: `'40px'`. This makes the expandable row pattern discoverable.
### 3.7 Add duplicate name validation
Before creating, check for existing names:
- Users: `users.some(u => u.username === newUsername.trim())`
- Groups: `groups.some(g => g.name.toLowerCase() === newName.trim().toLowerCase())`
- Roles: `roles.some(r => r.name === newName.trim().toUpperCase())`
Show inline error using state + red text below the name field. Disable Create button.
### 3.8 Partial FilterBar migration for AuditLog
After DataTable migration, use FilterBar for search + category filters:
- Search input maps to FilterBar's built-in search
- Categories (INFRA, AUTH, USER_MGMT, CONFIG) become FilterPill toggles
- Keep DateRangePicker and user filter Input alongside FilterBar in a row
### 3.9 Add empty-search states to entity lists
When search returns no results in Users/Groups/Roles lists, show centered muted text: "No users match your search" (etc.) inside the `.entityList` area.
---
## Tier 4: Polish
### 4.1 Replace lock emoji with Badge
`RolesTab.tsx`: replace `🔒` with `<Badge label="system" color="auto" variant="outlined" />`.
### 4.2 Fix split-pane border radius
`UserManagement.module.css`: change `border-radius: var(--radius-md)` to `var(--radius-lg)` on `.splitPane`, `.listPane`, `.detailPane`.
### 4.3 Add shadow to split-pane
`UserManagement.module.css`: add `box-shadow: var(--shadow-card)` to `.splitPane`.
---
## Out of Scope
- Replacing split-pane with DataTable+DetailPanel (not appropriate for dense editing)
- EventFeed as alternative audit view (future enhancement)
- Tabs inside user detail pane (not needed until more sections are added)
- FilterBar extension to support DateRangePicker slots (separate design system ticket)

View File

@@ -1,12 +1,14 @@
import { useMemo, useCallback } from 'react' import { useMemo, useCallback } from 'react'
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom' import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import { Dashboard } from './pages/Dashboard/Dashboard' import { Dashboard } from './pages/Dashboard/Dashboard'
import { Metrics } from './pages/Metrics/Metrics' import { Routes as RoutesPage } from './pages/Routes/Routes'
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail' import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
import { AgentHealth } from './pages/AgentHealth/AgentHealth' import { AgentHealth } from './pages/AgentHealth/AgentHealth'
import { AgentInstance } from './pages/AgentInstance/AgentInstance'
import { Inventory } from './pages/Inventory/Inventory' import { Inventory } from './pages/Inventory/Inventory'
import { Admin } from './pages/Admin/Admin' import { AuditLog } from './pages/Admin/AuditLog/AuditLog'
import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig'
import { UserManagement } from './pages/Admin/UserManagement/UserManagement'
import { ApiDocs } from './pages/ApiDocs/ApiDocs' import { ApiDocs } from './pages/ApiDocs/ApiDocs'
import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette' import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette'
@@ -17,32 +19,31 @@ import { buildSearchData } from './mocks/searchData'
import { exchanges } from './mocks/exchanges' import { exchanges } from './mocks/exchanges'
import { routes } from './mocks/routes' import { routes } from './mocks/routes'
import { agents } from './mocks/agents' import { agents } from './mocks/agents'
import { SIDEBAR_APPS } from './mocks/sidebar' import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
const routeToApp = buildRouteToAppMap()
/** Compute which sidebar path to reveal for a given search result */ /** Compute which sidebar path to reveal for a given search result */
function computeSidebarRevealPath(result: SearchResult): string | undefined { function computeSidebarRevealPath(result: SearchResult): string | undefined {
if (!result.path) return undefined if (!result.path) return undefined
if (result.category === 'application') { if (result.category === 'application') {
// /apps/:id — already a sidebar node path
return result.path return result.path
} }
if (result.category === 'route') { if (result.category === 'route') {
// /routes/:id — already a sidebar node path
return result.path return result.path
} }
if (result.category === 'agent') { if (result.category === 'agent') {
// /agents/:appId/:agentId — already a sidebar node path
return result.path return result.path
} }
if (result.category === 'exchange') { if (result.category === 'exchange') {
// /exchanges/:id — no sidebar entry; resolve to the parent route
const exchange = exchanges.find((e) => e.id === result.id) const exchange = exchanges.find((e) => e.id === result.id)
if (exchange) { if (exchange) {
return `/routes/${exchange.route}` const appId = routeToApp.get(exchange.route)
if (appId) return `/apps/${appId}/${exchange.route}`
} }
} }
@@ -80,11 +81,17 @@ export default function App() {
<Route path="/" element={<Navigate to="/apps" replace />} /> <Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} /> <Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} /> <Route path="/apps/:id" element={<Dashboard />} />
<Route path="/metrics" element={<Metrics />} /> <Route path="/apps/:id/:routeId" element={<Dashboard />} />
<Route path="/routes/:id" element={<RouteDetail />} /> <Route path="/routes" element={<RoutesPage />} />
<Route path="/routes/:appId" element={<RoutesPage />} />
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} /> <Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
<Route path="/agents/*" element={<AgentHealth />} /> <Route path="/agents/*" element={<AgentHealth />} />
<Route path="/admin" element={<Admin />} /> <Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
<Route path="/admin/audit" element={<AuditLog />} />
<Route path="/admin/oidc" element={<OidcConfig />} />
<Route path="/admin/rbac" element={<UserManagement />} />
<Route path="/api-docs" element={<ApiDocs />} /> <Route path="/api-docs" element={<ApiDocs />} />
<Route path="/inventory" element={<Inventory />} /> <Route path="/inventory" element={<Inventory />} />
</Routes> </Routes>

View File

@@ -0,0 +1,58 @@
.content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
font-family: var(--font-body);
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
}
.message {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--text-secondary);
}
.input {
width: 100%;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.input:focus {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.buttonRow {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
}

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ConfirmDialog } from './ConfirmDialog'
const defaultProps = {
open: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
message: 'Delete user "alice"? This cannot be undone.',
confirmText: 'alice',
}
describe('ConfirmDialog', () => {
it('renders title and message when open', () => {
render(<ConfirmDialog {...defaultProps} />)
expect(screen.getByText('Confirm Deletion')).toBeInTheDocument()
expect(screen.getByText('Delete user "alice"? This cannot be undone.')).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<ConfirmDialog {...defaultProps} open={false} />)
expect(screen.queryByText('Confirm Deletion')).not.toBeInTheDocument()
})
it('renders custom title', () => {
render(<ConfirmDialog {...defaultProps} title="Remove item" />)
expect(screen.getByText('Remove item')).toBeInTheDocument()
})
it('shows confirm instruction text', () => {
render(<ConfirmDialog {...defaultProps} />)
expect(screen.getByText(/Type "alice" to confirm/)).toBeInTheDocument()
})
it('disables confirm button until text matches', () => {
render(<ConfirmDialog {...defaultProps} />)
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
})
it('enables confirm button when text matches', async () => {
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} />)
await user.type(screen.getByRole('textbox'), 'alice')
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled()
})
it('calls onConfirm when confirm button is clicked after typing', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
await user.type(screen.getByRole('textbox'), 'alice')
await user.click(screen.getByRole('button', { name: 'Delete' }))
expect(onConfirm).toHaveBeenCalledOnce()
})
it('calls onClose when cancel button is clicked', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} onClose={onClose} />)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onConfirm on Enter when text matches', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
await user.type(screen.getByRole('textbox'), 'alice')
await user.keyboard('{Enter}')
expect(onConfirm).toHaveBeenCalledOnce()
})
it('does not call onConfirm on Enter when text does not match', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
await user.type(screen.getByRole('textbox'), 'alic')
await user.keyboard('{Enter}')
expect(onConfirm).not.toHaveBeenCalled()
})
it('disables both buttons when loading', async () => {
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} loading />)
await user.type(screen.getByRole('textbox'), 'alice')
const buttons = screen.getAllByRole('button')
for (const btn of buttons) {
expect(btn).toBeDisabled()
}
})
it('clears input when opened', async () => {
const { rerender } = render(<ConfirmDialog {...defaultProps} open={false} />)
rerender(<ConfirmDialog {...defaultProps} open={true} />)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
it('auto-focuses input on open', async () => {
render(<ConfirmDialog {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveFocus()
})
})
it('renders custom button labels', () => {
render(<ConfirmDialog {...defaultProps} confirmLabel="Remove" cancelLabel="Keep" />)
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,97 @@
import { useState, useEffect, useRef } from 'react'
import { Modal } from '../Modal/Modal'
import { Button } from '../../primitives/Button/Button'
import styles from './ConfirmDialog.module.css'
export interface ConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
title?: string
message: string
confirmText: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
loading?: boolean
className?: string
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title = 'Confirm Deletion',
message,
confirmText,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
variant = 'danger',
loading = false,
className,
}: ConfirmDialogProps) {
const [input, setInput] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const matches = input === confirmText
useEffect(() => {
if (open) {
setInput('')
const id = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(id)
}
}, [open])
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && matches && !loading) {
e.preventDefault()
onConfirm()
}
}
const confirmButtonVariant = variant === 'danger' ? 'danger' : 'primary'
return (
<Modal open={open} onClose={onClose} size="sm" className={className}>
<div className={styles.content}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.message}>{message}</p>
<div className={styles.inputGroup}>
<label className={styles.label} htmlFor="confirm-input">
{`Type "${confirmText}" to confirm`}
</label>
<input
ref={inputRef}
id="confirm-input"
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
</div>
<div className={styles.buttonRow}>
<Button
variant="secondary"
onClick={onClose}
disabled={loading}
type="button"
>
{cancelLabel}
</Button>
<Button
variant={confirmButtonVariant}
onClick={onConfirm}
loading={loading}
disabled={!matches || loading}
type="button"
>
{confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -11,15 +11,16 @@ interface DetailPanelProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
title: string title: string
tabs: Tab[] tabs?: Tab[]
children?: ReactNode
actions?: ReactNode actions?: ReactNode
className?: string className?: string
} }
export function DetailPanel({ open, onClose, title, tabs, actions, className }: DetailPanelProps) { export function DetailPanel({ open, onClose, title, tabs, children, actions, className }: DetailPanelProps) {
const [activeTab, setActiveTab] = useState(tabs[0]?.value ?? '') const [activeTab, setActiveTab] = useState(tabs?.[0]?.value ?? '')
const activeContent = tabs.find((t) => t.value === activeTab)?.content const activeContent = tabs?.find((t) => t.value === activeTab)?.content
return ( return (
<aside <aside
@@ -38,7 +39,7 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
</button> </button>
</div> </div>
{tabs.length > 0 && ( {tabs && tabs.length > 0 && (
<div className={styles.tabs}> <div className={styles.tabs}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
@@ -54,7 +55,7 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
)} )}
<div className={styles.body}> <div className={styles.body}>
{activeContent} {children ?? activeContent}
</div> </div>
{actions && ( {actions && (

View File

@@ -1,6 +1,7 @@
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react' import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import styles from './EventFeed.module.css' import styles from './EventFeed.module.css'
import { FilterPill } from '../../primitives/FilterPill/FilterPill' import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
export interface FeedEvent { export interface FeedEvent {
id: string id: string
@@ -53,6 +54,13 @@ const DEFAULT_ICONS: Record<SeverityFilter, string> = {
running: '\u2699', // ⚙ running: '\u2699', // ⚙
} }
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
error: 'var(--error)',
warning: 'var(--warning)',
success: 'var(--success)',
running: 'var(--running)',
}
const SEVERITY_LABELS: Record<SeverityFilter, string> = { const SEVERITY_LABELS: Record<SeverityFilter, string> = {
error: 'Error', error: 'Error',
warning: 'Warning', warning: 'Warning',
@@ -133,18 +141,15 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
)} )}
</div> </div>
<div className={styles.filters}> <div className={styles.filters}>
{allSeverities.map((sev) => { <ButtonGroup
const count = events.filter((e) => e.severity === sev).length items={allSeverities.map((sev): ButtonGroupItem => ({
return ( value: sev,
<FilterPill label: SEVERITY_LABELS[sev],
key={sev} color: SEVERITY_COLORS[sev],
label={SEVERITY_LABELS[sev]} }))}
count={count} value={activeFilters as Set<string>}
active={activeFilters.has(sev)} onChange={(next) => setActiveFilters(next as Set<SeverityFilter>)}
onClick={() => toggleFilter(sev)} />
/>
)
})}
{activeFilters.size > 0 && ( {activeFilters.size > 0 && (
<button <button
className={styles.clearBtn} className={styles.clearBtn}

View File

@@ -16,12 +16,12 @@
.item:hover { .item:hover {
background: var(--sidebar-hover); background: var(--sidebar-hover);
color: #E8DFD4; color: var(--sidebar-text);
} }
.item.active { .item.active {
background: var(--sidebar-active); background: var(--sidebar-active);
color: var(--amber-light); color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }
@@ -69,5 +69,5 @@
.item.active .count { .item.active .count {
background: rgba(198, 130, 14, 0.2); background: rgba(198, 130, 14, 0.2);
color: var(--amber-light); color: var(--amber);
} }

View File

@@ -0,0 +1,148 @@
.wrap {
position: relative;
display: inline-block;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
gap: 8px;
min-width: 0;
}
.trigger:focus-visible {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.trigger[aria-disabled="true"] {
opacity: 0.6;
cursor: not-allowed;
}
.triggerText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.triggerPlaceholder {
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
}
.panel {
position: fixed;
z-index: 1000;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
animation: panelIn 0.12s ease-out;
}
@keyframes panelIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.search {
padding: 8px 12px;
border: none;
border-bottom: 1px solid var(--border-subtle);
background: transparent;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
}
.search::placeholder {
color: var(--text-faint);
}
.optionList {
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
}
.option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
font-family: var(--font-body);
color: var(--text-primary);
transition: background 0.1s;
}
.option:hover {
background: var(--bg-hover);
}
.checkbox {
accent-color: var(--amber);
cursor: pointer;
}
.optionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: 12px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
.footer {
padding: 8px 12px;
border-top: 1px solid var(--border-subtle);
display: flex;
justify-content: flex-end;
}
.applyBtn {
padding: 4px 16px;
border: none;
border-radius: var(--radius-sm);
background: var(--amber);
color: var(--bg-base);
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.applyBtn:hover {
background: var(--amber-hover);
}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MultiSelect } from './MultiSelect'
const OPTIONS = [
{ value: 'admin', label: 'ADMIN' },
{ value: 'editor', label: 'EDITOR' },
{ value: 'viewer', label: 'VIEWER' },
{ value: 'operator', label: 'OPERATOR' },
]
describe('MultiSelect', () => {
it('renders trigger with placeholder', () => {
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
expect(screen.getByText('Select...')).toBeInTheDocument()
})
it('renders trigger with custom placeholder', () => {
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} placeholder="Add roles..." />)
expect(screen.getByText('Add roles...')).toBeInTheDocument()
})
it('shows selected count on trigger', () => {
render(<MultiSelect options={OPTIONS} value={['admin', 'editor']} onChange={vi.fn()} />)
expect(screen.getByText('2 selected')).toBeInTheDocument()
})
it('opens dropdown on trigger click', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
await user.click(screen.getByRole('combobox'))
expect(screen.getByText('ADMIN')).toBeInTheDocument()
expect(screen.getByText('EDITOR')).toBeInTheDocument()
})
it('shows checkboxes for pre-selected values', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
await user.click(screen.getByRole('combobox'))
const adminCheckbox = screen.getByRole('checkbox', { name: 'ADMIN' })
expect(adminCheckbox).toBeChecked()
})
it('filters options by search text', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
await user.click(screen.getByRole('combobox'))
await user.type(screen.getByPlaceholderText('Search...'), 'adm')
expect(screen.getByText('ADMIN')).toBeInTheDocument()
expect(screen.queryByText('EDITOR')).not.toBeInTheDocument()
})
it('calls onChange with selected values on Apply', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
await user.click(screen.getByRole('checkbox', { name: 'VIEWER' }))
await user.click(screen.getByRole('button', { name: /Apply/ }))
expect(onChange).toHaveBeenCalledWith(['admin', 'viewer'])
})
it('discards pending changes on Escape', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
await user.keyboard('{Escape}')
expect(onChange).not.toHaveBeenCalled()
})
it('closes dropdown on outside click without applying', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<div>
<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />
<button>Outside</button>
</div>
)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
await user.click(screen.getByText('Outside'))
expect(onChange).not.toHaveBeenCalled()
})
it('disables trigger when disabled prop is set', () => {
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} disabled />)
expect(screen.getByRole('combobox')).toHaveAttribute('aria-disabled', 'true')
})
it('hides search input when searchable is false', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} searchable={false} />)
await user.click(screen.getByRole('combobox'))
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
})
it('shows Apply button with count of pending changes', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('checkbox', { name: 'EDITOR' }))
expect(screen.getByRole('button', { name: /Apply \(2\)/ })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,162 @@
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import styles from './MultiSelect.module.css'
export interface MultiSelectOption {
value: string
label: string
}
interface MultiSelectProps {
options: MultiSelectOption[]
value: string[]
onChange: (value: string[]) => void
placeholder?: string
searchable?: boolean
disabled?: boolean
className?: string
}
export function MultiSelect({
options,
value,
onChange,
placeholder = 'Select...',
searchable = true,
disabled = false,
className,
}: MultiSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [pending, setPending] = useState<string[]>(value)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
// Sync pending with value when opening
useEffect(() => {
if (open) {
setPending(value)
setSearch('')
}
}, [open, value])
// Position the panel below the trigger
useEffect(() => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
setPos({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}
}, [open])
// Close on outside click
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (
panelRef.current && !panelRef.current.contains(e.target as Node) &&
triggerRef.current && !triggerRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open])
function toggleOption(optValue: string) {
setPending((prev) =>
prev.includes(optValue) ? prev.filter((v) => v !== optValue) : [...prev, optValue]
)
}
function handleApply() {
onChange(pending)
setOpen(false)
}
const filtered = options.filter((opt) =>
opt.label.toLowerCase().includes(search.toLowerCase())
)
const triggerLabel = value.length > 0 ? `${value.length} selected` : placeholder
return (
<div className={`${styles.wrap} ${className ?? ''}`}>
<button
ref={triggerRef}
className={styles.trigger}
onClick={() => !disabled && setOpen(!open)}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-disabled={disabled}
type="button"
>
<span className={value.length > 0 ? styles.triggerText : styles.triggerPlaceholder}>
{triggerLabel}
</span>
<span className={styles.chevron} aria-hidden="true"></span>
</button>
{open && createPortal(
<div
ref={panelRef}
className={styles.panel}
style={{ top: pos.top, left: pos.left, width: Math.max(pos.width, 200) }}
>
{searchable && (
<input
className={styles.search}
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
)}
<div className={styles.optionList} role="listbox">
{filtered.map((opt) => (
<label key={opt.value} className={styles.option} role="option" aria-selected={pending.includes(opt.value)}>
<input
type="checkbox"
className={styles.checkbox}
checked={pending.includes(opt.value)}
onChange={() => toggleOption(opt.value)}
aria-label={opt.label}
/>
<span className={styles.optionLabel}>{opt.label}</span>
</label>
))}
{filtered.length === 0 && (
<div className={styles.empty}>No matches</div>
)}
</div>
<div className={styles.footer}>
<button
className={styles.applyBtn}
onClick={handleApply}
type="button"
>
Apply ({pending.length})
</button>
</div>
</div>,
document.body,
)}
</div>
)
}

View File

@@ -69,15 +69,15 @@
} }
.ok { .ok {
background: rgba(61, 124, 71, 0.5); background: color-mix(in srgb, var(--success) 50%, transparent);
} }
.slow { .slow {
background: rgba(194, 117, 22, 0.5); background: color-mix(in srgb, var(--warning) 50%, transparent);
} }
.fail { .fail {
background: rgba(192, 57, 43, 0.5); background: color-mix(in srgb, var(--error) 50%, transparent);
} }
.dur { .dur {
@@ -89,6 +89,13 @@
text-align: right; text-align: right;
} }
.selectedRow {
background: var(--amber-bg);
border-left: 3px solid var(--amber);
border-radius: var(--radius-sm);
padding: 2px 0 2px 4px;
}
.empty { .empty {
color: var(--text-muted); color: var(--text-muted);
font-size: 12px; font-size: 12px;

View File

@@ -11,7 +11,8 @@ export interface ProcessorStep {
interface ProcessorTimelineProps { interface ProcessorTimelineProps {
processors: ProcessorStep[] processors: ProcessorStep[]
totalMs: number totalMs: number
onProcessorClick?: (processor: ProcessorStep) => void onProcessorClick?: (processor: ProcessorStep, index: number) => void
selectedIndex?: number
className?: string className?: string
} }
@@ -24,6 +25,7 @@ export function ProcessorTimeline({
processors, processors,
totalMs, totalMs,
onProcessorClick, onProcessorClick,
selectedIndex,
className, className,
}: ProcessorTimelineProps) { }: ProcessorTimelineProps) {
const safeTotal = totalMs || 1 const safeTotal = totalMs || 1
@@ -49,17 +51,19 @@ export function ProcessorTimeline({
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
const isSelected = selectedIndex === i
return ( return (
<div <div
key={i} key={i}
className={`${styles.row} ${onProcessorClick ? styles.clickable : ''}`} className={`${styles.row} ${onProcessorClick ? styles.clickable : ''} ${isSelected ? styles.selectedRow : ''}`}
onClick={() => onProcessorClick?.(proc)} onClick={() => onProcessorClick?.(proc, i)}
role={onProcessorClick ? 'button' : undefined} role={onProcessorClick ? 'button' : undefined}
tabIndex={onProcessorClick ? 0 : undefined} tabIndex={onProcessorClick ? 0 : undefined}
onKeyDown={(e) => { onKeyDown={(e) => {
if (onProcessorClick && (e.key === 'Enter' || e.key === ' ')) { if (onProcessorClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault() e.preventDefault()
onProcessorClick(proc) onProcessorClick(proc, i)
} }
}} }}
aria-label={`${proc.name}: ${formatDuration(proc.durationMs)} (${proc.status})`} aria-label={`${proc.name}: ${formatDuration(proc.durationMs)} (${proc.status})`}

View File

@@ -0,0 +1,204 @@
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 12px 0;
}
/* Processor node */
.node {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background: var(--bg-surface);
cursor: default;
transition: all 0.12s;
position: relative;
}
.node:hover {
box-shadow: var(--shadow-sm);
border-color: var(--text-faint);
}
.nodeHealthy {
border-left: 3px solid var(--success);
}
.nodeSlow {
border-left: 3px solid var(--warning);
}
.nodeError {
border-left: 3px solid var(--error);
}
.nodeBottleneck {
border-left: 3px solid var(--error);
background: var(--warning-bg);
border-color: var(--warning-border);
}
/* Icon */
.icon {
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
flex-shrink: 0;
}
.iconFrom {
background: var(--running-bg);
color: var(--running);
}
.iconProcess {
background: var(--amber-bg);
color: var(--amber);
}
.iconTo {
background: var(--success-bg);
color: var(--success);
}
.iconChoice {
background: var(--purple-bg);
color: var(--purple);
}
.iconErrorHandler {
background: var(--error-bg);
color: var(--error);
}
/* Node info */
.info {
flex: 1;
min-width: 0;
}
.type {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Node stats */
.stats {
text-align: right;
flex-shrink: 0;
}
.duration {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
}
.durFast {
color: var(--success);
}
.durNormal {
color: var(--text-primary);
}
.durSlow {
color: var(--warning);
}
.durBreach {
color: var(--error);
}
/* Connector */
.connector {
display: flex;
flex-direction: column;
align-items: center;
height: 16px;
}
.connectorLine {
width: 1px;
flex: 1;
background: var(--border);
}
.connectorArrow {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid var(--border);
}
/* Error handler section */
.errorSection {
width: 100%;
margin-top: 4px;
padding-top: 8px;
border-top: 1px dashed var(--border);
}
.errorLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--error);
margin-bottom: 6px;
padding-left: 2px;
}
/* Selected node */
.nodeSelected {
box-shadow: 0 0 0 2px var(--amber);
border-color: var(--amber);
}
/* Clickable node */
.nodeClickable {
cursor: pointer;
}
.nodeClickable:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
/* Bottleneck badge */
.bottleneckBadge {
position: absolute;
top: -7px;
right: 8px;
font-family: var(--font-mono);
font-size: 8px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
background: var(--error);
color: #fff;
letter-spacing: 0.3px;
}

View File

@@ -0,0 +1,133 @@
import styles from './RouteFlow.module.css'
export interface RouteNode {
name: string
type: 'from' | 'process' | 'to' | 'choice' | 'error-handler'
durationMs: number
status: 'ok' | 'slow' | 'fail'
isBottleneck?: boolean
}
interface RouteFlowProps {
nodes: RouteNode[]
onNodeClick?: (node: RouteNode, index: number) => void
selectedIndex?: number
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
if (ms < 150) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}
const TYPE_ICONS: Record<string, string> = {
'from': '\u25B6',
'process': '\u2699',
'to': '\u25A2',
'choice': '\u25C6',
'error-handler': '\u26A0',
}
const ICON_CLASSES: Record<string, string> = {
'from': styles.iconFrom,
'process': styles.iconProcess,
'to': styles.iconTo,
'choice': styles.iconChoice,
'error-handler': styles.iconErrorHandler,
}
function nodeStatusClass(node: RouteNode): string {
if (node.isBottleneck) return styles.nodeBottleneck
if (node.status === 'fail') return styles.nodeError
if (node.status === 'slow') return styles.nodeSlow
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')
// Map from mainNodes index back to original nodes index
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
if (n.type !== 'error-handler') acc.push(idx)
return acc
}, [])
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{mainNodes.map((node, i) => {
const originalIndex = mainNodeOriginalIndices[i]
const isSelected = selectedIndex === originalIndex
const isClickable = !!onNodeClick
return (
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{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, originalIndex)}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onKeyDown={(e) => {
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onNodeClick?.(node, originalIndex)
}
}}
>
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'}
</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>
</div>
</div>
)
})}
{errorHandlers.length > 0 && (
<div className={styles.errorSection}>
<div className={styles.errorLabel}>Error Handler</div>
{errorHandlers.map((node, i) => (
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
{TYPE_ICONS['error-handler']}
</div>
<div className={styles.info}>
<div className={styles.type}>{node.type}</div>
<div className={styles.label} title={node.name}>{node.name}</div>
</div>
<div className={styles.stats}>
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
{formatDuration(node.durationMs)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,79 @@
.bar {
position: relative;
display: inline-flex;
align-items: center;
border-radius: var(--radius-md);
background: var(--bg-inset);
padding: 2px;
gap: 1px;
}
/* Sliding indicator behind the active tab */
.indicator {
position: absolute;
top: 2px;
bottom: 2px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: calc(var(--radius-md) - 2px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
transition: left 0.2s ease, width 0.2s ease;
pointer-events: none;
}
.tab {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 3px 12px;
font-size: 12px;
font-weight: 500;
font-family: var(--font-body);
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: calc(var(--radius-md) - 2px);
cursor: pointer;
transition: color 0.15s;
white-space: nowrap;
line-height: 1.5;
}
.tab:hover {
color: var(--text-primary);
}
.active {
color: var(--text-primary);
font-weight: 600;
}
.label {
line-height: 1;
}
.count {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
background: var(--bg-hover);
color: var(--text-muted);
padding: 1px 5px;
border-radius: 8px;
line-height: 1.4;
}
.active .count {
background: var(--amber-bg);
color: var(--amber);
}
/* Trailing tab — a div, not a button, hosting custom content */
.trailingTab {
cursor: default;
gap: 4px;
padding: 3px 10px;
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SegmentedTabs } from './SegmentedTabs'
const TABS = [
{ label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups', count: 4 },
{ label: 'Roles', value: 'roles' },
]
describe('SegmentedTabs', () => {
it('renders all tabs', () => {
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
expect(screen.getByRole('tab', { name: /Users/ })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /Groups/ })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /Roles/ })).toBeInTheDocument()
})
it('marks the active tab with aria-selected', () => {
render(<SegmentedTabs tabs={TABS} active="groups" onChange={vi.fn()} />)
expect(screen.getByRole('tab', { name: /Groups/ })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: /Users/ })).toHaveAttribute('aria-selected', 'false')
})
it('calls onChange when a tab is clicked', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<SegmentedTabs tabs={TABS} active="users" onChange={onChange} />)
await user.click(screen.getByRole('tab', { name: /Roles/ }))
expect(onChange).toHaveBeenCalledWith('roles')
})
it('renders count badge when provided', () => {
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
expect(screen.getByText('4')).toBeInTheDocument()
})
it('has tablist role on container', () => {
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
expect(screen.getByRole('tablist')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,101 @@
import { useRef, useEffect, useState as useLocalState, useCallback, useMemo, type ReactNode } from 'react'
import styles from './SegmentedTabs.module.css'
interface TabItem {
label: ReactNode
count?: number
value: string
}
interface SegmentedTabsProps {
tabs: TabItem[]
active: string
onChange: (value: string) => void
/** Extra element rendered as the last "tab" — participates in indicator animation.
* Use `trailingValue` to assign it a value for active state matching. */
trailing?: ReactNode
trailingValue?: string
className?: string
}
export function SegmentedTabs({ tabs, active, onChange, trailing, trailingValue, className }: SegmentedTabsProps) {
const barRef = useRef<HTMLDivElement>(null)
const tabRefs = useRef<Map<string, HTMLElement>>(new Map())
const [indicator, setIndicator] = useLocalState<{ left: number; width: number } | null>(null)
// Recalculate when labels change (e.g. dynamic date range text)
const tabsKey = useMemo(() => tabs.map((t) => `${t.value}:${typeof t.label === 'string' ? t.label : ''}`).join('|'), [tabs])
const updateIndicator = useCallback(() => {
const bar = barRef.current
const activeEl = tabRefs.current.get(active)
if (!bar || !activeEl) return
const barRect = bar.getBoundingClientRect()
const elRect = activeEl.getBoundingClientRect()
setIndicator({
left: elRect.left - barRect.left,
width: elRect.width,
})
}, [active, tabsKey]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const id = requestAnimationFrame(updateIndicator)
return () => cancelAnimationFrame(id)
}, [updateIndicator])
useEffect(() => {
window.addEventListener('resize', updateIndicator)
return () => window.removeEventListener('resize', updateIndicator)
}, [updateIndicator])
// Observe DOM mutations (e.g. trailing content text changes) to resize indicator
useEffect(() => {
const bar = barRef.current
if (!bar) return
const observer = new MutationObserver(() => {
requestAnimationFrame(updateIndicator)
})
observer.observe(bar, { childList: true, subtree: true, characterData: true })
return () => observer.disconnect()
}, [updateIndicator])
const trailingActive = trailingValue !== undefined && active === trailingValue
return (
<div ref={barRef} className={`${styles.bar} ${className ?? ''}`} role="tablist">
{indicator && (
<span
className={styles.indicator}
style={{ left: indicator.left, width: indicator.width }}
aria-hidden="true"
/>
)}
{tabs.map((tab) => (
<button
key={tab.value}
ref={(el) => { if (el) tabRefs.current.set(tab.value, el); else tabRefs.current.delete(tab.value) }}
role="tab"
aria-selected={tab.value === active}
className={`${styles.tab} ${tab.value === active ? styles.active : ''}`}
onClick={() => onChange(tab.value)}
type="button"
>
<span className={styles.label}>{tab.label}</span>
{tab.count !== undefined && (
<span className={styles.count}>{tab.count}</span>
)}
</button>
))}
{trailing && trailingValue && (
<div
ref={(el) => { if (el) tabRefs.current.set(trailingValue, el); else tabRefs.current.delete(trailingValue) }}
role="tab"
aria-selected={trailingActive}
className={`${styles.tab} ${styles.trailingTab} ${trailingActive ? styles.active : ''}`}
>
{trailing}
</div>
)}
</div>
)
}

View File

@@ -6,6 +6,8 @@ export { BarChart } from './BarChart/BarChart'
export { Breadcrumb } from './Breadcrumb/Breadcrumb' export { Breadcrumb } from './Breadcrumb/Breadcrumb'
export { CommandPalette } from './CommandPalette/CommandPalette' export { CommandPalette } from './CommandPalette/CommandPalette'
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types' export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
export { ConfirmDialog } from './ConfirmDialog/ConfirmDialog'
export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog'
export { DataTable } from './DataTable/DataTable' export { DataTable } from './DataTable/DataTable'
export type { Column, DataTableProps } from './DataTable/types' export type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel' export { DetailPanel } from './DetailPanel/DetailPanel'
@@ -17,10 +19,15 @@ export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart' export { LineChart } from './LineChart/LineChart'
export { MenuItem } from './MenuItem/MenuItem' export { MenuItem } from './MenuItem/MenuItem'
export { Modal } from './Modal/Modal' export { Modal } from './Modal/Modal'
export { MultiSelect } from './MultiSelect/MultiSelect'
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
export { Popover } from './Popover/Popover' export { Popover } from './Popover/Popover'
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline' export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline' export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
export { RouteFlow } from './RouteFlow/RouteFlow'
export type { RouteNode } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar' export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { Tabs } from './Tabs/Tabs' export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast' export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView' export { TreeView } from './TreeView/TreeView'

View File

@@ -20,7 +20,7 @@
.logoImg { .logoImg {
width: 28px; width: 28px;
height: 24px; height: 24px;
color: var(--amber-light); color: var(--amber);
filter: brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%); filter: brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%);
} }
@@ -28,7 +28,7 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
font-size: 15px; font-size: 15px;
color: var(--amber-light); color: var(--amber);
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
@@ -151,7 +151,7 @@
.item.active { .item.active {
background: var(--sidebar-active); background: var(--sidebar-active);
color: var(--amber-light); color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }
@@ -164,7 +164,7 @@
} }
.item.active .navIcon { .item.active .navIcon {
color: var(--amber-light); color: var(--amber);
} }
.routeArrow { .routeArrow {
@@ -197,8 +197,9 @@
/* ── SidebarTree styles ──────────────────────────────────────────────────── */ /* ── SidebarTree styles ──────────────────────────────────────────────────── */
.treeSection { .treeSection {
padding: 0 6px; padding: 0 6px 6px;
margin-bottom: 4px; margin-bottom: 2px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
} }
.treeSectionLabel { .treeSectionLabel {
@@ -214,9 +215,9 @@
.treeSectionToggle { .treeSectionToggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 2px;
width: 100%; width: 100%;
padding: 8px 12px 4px; padding: 8px 0 4px;
} }
.treeSectionChevronBtn { .treeSectionChevronBtn {
@@ -248,11 +249,11 @@
} }
.treeSectionLabel:hover { .treeSectionLabel:hover {
color: var(--amber-light); color: var(--amber);
} }
.treeSectionLabelActive { .treeSectionLabelActive {
color: var(--amber-light); color: var(--amber);
} }
.tree { .tree {
@@ -289,13 +290,13 @@
.treeRowActive { .treeRowActive {
background: var(--sidebar-active); background: var(--sidebar-active);
color: var(--amber-light); color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }
.treeRowActive .treeBadge { .treeRowActive .treeBadge {
background: rgba(198, 130, 14, 0.2); background: rgba(198, 130, 14, 0.2);
color: var(--amber-light); color: var(--amber);
} }
/* Chevron */ /* Chevron */
@@ -379,7 +380,7 @@
} }
.treeStar:hover { .treeStar:hover {
color: var(--amber-light); color: var(--amber);
} }
/* ── Starred section ─────────────────────────────────────────────────────── */ /* ── Starred section ─────────────────────────────────────────────────────── */
@@ -499,7 +500,7 @@
.bottomItemActive { .bottomItemActive {
background: var(--sidebar-active); background: var(--sidebar-active);
color: var(--amber-light); color: var(--amber);
border-left-color: var(--amber); border-left-color: var(--amber);
} }

View File

@@ -74,9 +74,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Agents')).toBeInTheDocument() expect(screen.getByText('Agents')).toBeInTheDocument()
}) })
it('renders Metrics nav link', () => { it('renders Routes nav link', () => {
renderSidebar() renderSidebar()
expect(screen.getByText('Metrics')).toBeInTheDocument() expect(screen.getByText('Routes')).toBeInTheDocument()
}) })
it('renders bottom links', () => { it('renders bottom links', () => {
@@ -87,9 +87,9 @@ describe('Sidebar', () => {
it('renders app names in the Applications tree', () => { it('renders app names in the Applications tree', () => {
renderSidebar() renderSidebar()
// order-service appears in both Applications and Agents trees // order-service appears in Applications, Routes, and Agents trees
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1) expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('payment-svc')).toBeInTheDocument() expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
}) })
it('renders exchange count badges', () => { it('renders exchange count badges', () => {
@@ -130,8 +130,8 @@ describe('Sidebar', () => {
const searchInput = screen.getByPlaceholderText('Filter...') const searchInput = screen.getByPlaceholderText('Filter...')
await user.type(searchInput, 'payment') await user.type(searchInput, 'payment')
// payment-svc should still be visible // payment-svc should still be visible (may appear in multiple trees)
expect(screen.getByText('payment-svc')).toBeInTheDocument() expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
}) })
it('expands tree to show children when chevron is clicked', async () => { it('expands tree to show children when chevron is clicked', async () => {

View File

@@ -57,12 +57,35 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
label: route.name, label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>, icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount), badge: formatCount(route.exchangeCount),
path: `/routes/${route.id}`, path: `/apps/${app.id}/${route.id}`,
starrable: true, 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[] { function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps return apps
.filter((app) => app.agents.length > 0) .filter((app) => app.agents.length > 0)
@@ -95,7 +118,7 @@ interface StarredItem {
label: string label: string
icon?: React.ReactNode icon?: React.ReactNode
path: string path: string
type: 'application' | 'route' | 'agent' type: 'application' | 'route' | 'agent' | 'routestat'
parentApp?: string parentApp?: string
} }
@@ -118,24 +141,57 @@ function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): Starr
items.push({ items.push({
starKey: key, starKey: key,
label: route.name, label: route.name,
path: `/routes/${route.id}`, path: `/apps/${app.id}/${route.id}`,
type: 'route', type: 'route',
parentApp: app.name, 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) { for (const agent of app.agents) {
const key = `${app.id}:${agent.id}` const key = `${app.id}:${agent.id}`
if (starredIds.has(key)) { if (starredIds.has(key)) {
items.push({ items.push({
starKey: key, starKey: key,
label: agent.name, label: agent.name,
path: `/agents/${agent.id}`, path: `/agents/${app.id}/${agent.id}`,
type: 'agent', type: 'agent',
parentApp: app.name, 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 return items
@@ -196,6 +252,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true') const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-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) => { const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
_setAppsCollapsed((prev) => { _setAppsCollapsed((prev) => {
@@ -212,6 +269,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
return 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 navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred() const { starredIds, isStarred, toggleStar } = useStarred()
@@ -219,6 +284,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
// Build tree data // Build tree data
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps]) const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps]) const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
// Sidebar reveal from Cmd-K navigation (passed via location state) // Sidebar reveal from Cmd-K navigation (passed via location state)
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
@@ -254,6 +320,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
const starredApps = starredItems.filter((i) => i.type === 'application') const starredApps = starredItems.filter((i) => i.type === 'application')
const starredRoutes = starredItems.filter((i) => i.type === 'route') const starredRoutes = starredItems.filter((i) => i.type === 'route')
const starredAgents = starredItems.filter((i) => i.type === 'agent') const starredAgents = starredItems.filter((i) => i.type === 'agent')
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
const hasStarred = starredItems.length > 0 const hasStarred = starredItems.length > 0
// For exchange detail pages, use the reveal path for sidebar selection so // For exchange detail pages, use the reveal path for sidebar selection so
@@ -374,23 +441,38 @@ export function Sidebar({ apps, className }: SidebarProps) {
)} )}
</div> </div>
{/* Flat nav links */} {/* Routes tree (collapsible, label navigates to /routes) */}
<div className={styles.items}> <div className={styles.treeSection}>
<div <div className={styles.treeSectionToggle}>
className={[ <button
styles.item, className={styles.treeSectionChevronBtn}
location.pathname === '/metrics' ? styles.active : '', onClick={() => setRoutesCollapsed((v) => !v)}
].filter(Boolean).join(' ')} aria-expanded={!routesCollapsed}
onClick={() => navigate('/metrics')} aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
role="button" >
tabIndex={0} {routesCollapsed ? '▸' : '▾'}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }} </button>
> <span
<span className={styles.navIcon}></span> className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
<div className={styles.itemInfo}> onClick={() => navigate('/routes')}
<div className={styles.itemName}>Metrics</div> role="button"
</div> tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
>
Routes
</span>
</div> </div>
{!routesCollapsed && (
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
/>
)}
</div> </div>
{/* No results message */} {/* No results message */}
@@ -429,6 +511,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
onRemove={toggleStar} onRemove={toggleStar}
/> />
)} )}
{starredRouteStats.length > 0 && (
<StarredGroup
label="Routes"
items={starredRouteStats}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
</div> </div>
</div> </div>
)} )}
@@ -439,7 +529,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
<div <div
className={[ className={[
styles.bottomItem, styles.bottomItem,
location.pathname === '/admin' ? styles.bottomItemActive : '', location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
onClick={() => navigate('/admin')} onClick={() => navigate('/admin')}
role="button" role="button"

View File

@@ -81,6 +81,27 @@
flex-shrink: 0; flex-shrink: 0;
} }
.themeToggle {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
line-height: 1;
}
.themeToggle:hover {
color: var(--amber);
border-color: var(--amber);
}
.env { .env {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
@@ -100,6 +121,7 @@
gap: 8px; gap: 8px;
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer;
} }
.userName { .userName {

View File

@@ -1,10 +1,13 @@
import styles from './TopBar.module.css' import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb' import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown'
import { Avatar } from '../../primitives/Avatar/Avatar' import { Avatar } from '../../primitives/Avatar/Avatar'
import { FilterPill } from '../../primitives/FilterPill/FilterPill' import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown' import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters, type ExchangeStatus } from '../../providers/GlobalFilterProvider' import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider' import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider'
interface BreadcrumbItem { interface BreadcrumbItem {
label: string label: string
@@ -15,24 +18,27 @@ interface TopBarProps {
breadcrumb: BreadcrumbItem[] breadcrumb: BreadcrumbItem[]
environment?: string environment?: string
user?: { name: string } user?: { name: string }
onLogout?: () => void
className?: string className?: string
} }
const STATUS_PILLS: { status: ExchangeStatus; label: string }[] = [ const STATUS_ITEMS: ButtonGroupItem[] = [
{ status: 'completed', label: 'OK' }, { value: 'completed', label: 'OK', color: 'var(--success)' },
{ status: 'warning', label: 'Warn' }, { value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ status: 'failed', label: 'Error' }, { value: 'failed', label: 'Error', color: 'var(--error)' },
{ status: 'running', label: 'Running' }, { value: 'running', label: 'Running', color: 'var(--running)' },
] ]
export function TopBar({ export function TopBar({
breadcrumb, breadcrumb,
environment, environment,
user, user,
onLogout,
className, className,
}: TopBarProps) { }: TopBarProps) {
const globalFilters = useGlobalFilters() const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette() const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme()
return ( return (
<header className={`${styles.topbar} ${className ?? ''}`}> <header className={`${styles.topbar} ${className ?? ''}`}>
@@ -56,17 +62,21 @@ export function TopBar({
<span className={styles.kbd}>Ctrl+K</span> <span className={styles.kbd}>Ctrl+K</span>
</button> </button>
{/* Status pills */} {/* Status filter group */}
<div className={styles.filters}> <ButtonGroup
{STATUS_PILLS.map(({ status, label }) => ( items={STATUS_ITEMS}
<FilterPill value={globalFilters.statusFilters}
key={status} onChange={(selected) => {
label={label} // Sync with global filter by toggling the diff
active={globalFilters.statusFilters.has(status)} const current = globalFilters.statusFilters
onClick={() => globalFilters.toggleStatus(status)} for (const v of selected) {
/> if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
))} }
</div> for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/>
{/* Time range pills */} {/* Time range pills */}
<TimeRangeDropdown <TimeRangeDropdown
@@ -74,16 +84,32 @@ export function TopBar({
onChange={globalFilters.setTimeRange} onChange={globalFilters.setTimeRange}
/> />
{/* Right: env badge, user */} {/* Right: theme toggle, env badge, user */}
<div className={styles.right}> <div className={styles.right}>
<button
className={styles.themeToggle}
onClick={toggleTheme}
type="button"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '\u263E' : '\u2600'}
</button>
{environment && ( {environment && (
<span className={styles.env}>{environment}</span> <span className={styles.env}>{environment}</span>
)} )}
{user && ( {user && (
<div className={styles.user}> <Dropdown
<span className={styles.userName}>{user.name}</span> trigger={
<Avatar name={user.name} size="md" /> <div className={styles.user}>
</div> <span className={styles.userName}>{user.name}</span>
<Avatar name={user.name} size="md" />
</div>
}
items={[
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
]}
/>
)} )}
</div> </div>
</header> </header>

View File

@@ -20,7 +20,6 @@
} }
.dashed { .dashed {
background: transparent !important;
border-style: dashed; border-style: dashed;
} }

View File

@@ -1,60 +1,59 @@
.group { .group {
display: inline-flex; display: inline-flex;
isolation: isolate;
}
/* Horizontal (default) */
.horizontal {
flex-direction: row;
}
.horizontal > :global(*) {
border-radius: 0;
margin-left: -1px;
position: relative;
}
.horizontal > :global(*:first-child) {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
margin-left: 0;
}
.horizontal > :global(*:last-child) {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.horizontal > :global(*:only-child) {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-surface);
} }
/* Vertical */ .btn {
.vertical { display: inline-flex;
flex-direction: column; align-items: center;
gap: 5px;
padding: 5px 10px;
border: none;
border-right: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
line-height: 1.5;
} }
.vertical > :global(*) { .btn:last-child {
border-radius: 0; border-right: none;
margin-top: -1px;
position: relative;
} }
.vertical > :global(*:first-child) { .btn:hover {
border-radius: var(--radius-sm) var(--radius-sm) 0 0; background: var(--bg-hover);
margin-top: 0; color: var(--text-primary);
} }
.vertical > :global(*:last-child) { .btn:focus-visible {
border-radius: 0 0 var(--radius-sm) var(--radius-sm); outline: 2px solid var(--amber);
} outline-offset: -2px;
.vertical > :global(*:only-child) {
border-radius: var(--radius-sm);
}
/* Active/hovered items sit above siblings so their borders win */
.group > :global(*:hover),
.group > :global(*:focus-visible),
.group > :global(*[data-active="true"]),
.group > :global(*.active) {
z-index: 1; z-index: 1;
} }
/* Active state — default (no color override) */
.active {
background: var(--amber-bg);
color: var(--amber);
font-weight: 600;
}
/* Dot indicator */
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.dotMuted {
opacity: 0.4;
}

View File

@@ -1,23 +1,60 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import styles from './ButtonGroup.module.css' import styles from './ButtonGroup.module.css'
export interface ButtonGroupItem {
value: string
label: ReactNode
/** Optional color for dot indicator and active tint */
color?: string
}
interface ButtonGroupProps { interface ButtonGroupProps {
children: ReactNode items: ButtonGroupItem[]
orientation?: 'horizontal' | 'vertical' /** Currently selected values (multi-select) */
value: Set<string>
onChange: (value: Set<string>) => void
className?: string className?: string
} }
export function ButtonGroup({ export function ButtonGroup({ items, value, onChange, className }: ButtonGroupProps) {
children, function handleClick(itemValue: string) {
orientation = 'horizontal', const next = new Set(value)
className, if (next.has(itemValue)) {
}: ButtonGroupProps) { next.delete(itemValue)
} else {
next.add(itemValue)
}
onChange(next)
}
return ( return (
<div <div className={`${styles.group} ${className ?? ''}`} role="group">
className={`${styles.group} ${styles[orientation]} ${className ?? ''}`} {items.map((item) => {
role="group" const active = value.has(item.value)
>
{children} return (
<button
key={item.value}
type="button"
className={`${styles.btn} ${active ? styles.active : ''}`}
style={active && item.color ? {
borderColor: item.color,
color: item.color,
background: `color-mix(in srgb, ${item.color} 10%, transparent)`,
} : undefined}
onClick={() => handleClick(item.value)}
aria-pressed={active}
>
{item.color && (
<span
className={`${styles.dot} ${active ? '' : styles.dotMuted}`}
style={{ background: item.color }}
/>
)}
{item.label}
</button>
)
})}
</div> </div>
) )
} }

View File

@@ -4,15 +4,17 @@ import userEvent from '@testing-library/user-event'
import { DateRangePicker } from './DateRangePicker' import { DateRangePicker } from './DateRangePicker'
describe('DateRangePicker', () => { describe('DateRangePicker', () => {
it('renders two datetime inputs', () => { it('renders two datetime picker triggers', () => {
const { container } = render( render(
<DateRangePicker <DateRangePicker
value={{ start: new Date(), end: new Date() }} value={{ start: new Date('2026-03-19T10:00'), end: new Date('2026-03-19T11:00') }}
onChange={() => {}} onChange={() => {}}
/>, />,
) )
const inputs = container.querySelectorAll('input[type="datetime-local"]') // DateTimePicker renders button triggers with formatted date text
expect(inputs.length).toBe(2) const buttons = screen.getAllByRole('button')
// At least 2 buttons are the from/to date picker triggers (plus preset pills)
expect(buttons.length).toBeGreaterThanOrEqual(2)
}) })
it('renders preset buttons', () => { it('renders preset buttons', () => {

View File

@@ -12,26 +12,217 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.input { .trigger {
width: 100%; padding: 0 4px;
padding: 6px 10px; border: none;
background: transparent;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
transition: color 0.15s;
line-height: 1;
}
.trigger:hover {
color: var(--amber);
}
.trigger:focus-visible {
outline: 1px solid var(--amber);
outline-offset: 1px;
}
/* Panel */
.panel {
position: fixed;
z-index: 600;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 12px;
width: 260px;
animation: panelIn 0.12s ease-out;
}
@keyframes panelIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Month navigation */
.monthNav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.monthLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-body);
}
.navBtn {
border: none;
background: none;
color: var(--text-muted);
font-size: 10px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: background 0.1s, color 0.1s;
}
.navBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Calendar grid */
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 10px;
}
.dayHeader {
font-size: 10px;
font-weight: 600;
color: var(--text-faint);
text-align: center;
padding: 4px 0;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.dayEmpty {
/* placeholder for offset days */
}
.day {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-body);
cursor: pointer;
transition: background 0.1s;
}
.day:hover {
background: var(--bg-hover);
}
.dayToday {
font-weight: 700;
color: var(--amber);
}
.daySelected {
background: var(--amber);
color: #fff;
font-weight: 600;
}
.daySelected:hover {
background: var(--amber-hover);
}
/* Time selector */
.timeRow {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 0;
border-top: 1px solid var(--border-subtle);
}
.timeLabel {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
margin-right: auto;
}
.timeInput {
width: 32px;
padding: 4px 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-raised); background: var(--bg-raised);
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 13px;
text-align: center;
outline: none; outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
cursor: pointer;
} }
.input:focus { .timeInput:focus {
border-color: var(--amber); border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg); box-shadow: 0 0 0 2px var(--amber-bg);
} }
.input::-webkit-calendar-picker-indicator { .timeSep {
opacity: 0.5; font-size: 14px;
cursor: pointer; font-weight: 600;
color: var(--text-muted);
}
/* Actions */
.actions {
display: flex;
justify-content: space-between;
padding-top: 8px;
border-top: 1px solid var(--border-subtle);
}
.todayBtn {
border: none;
background: none;
color: var(--amber);
font-size: 12px;
font-weight: 500;
font-family: var(--font-body);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.todayBtn:hover {
background: var(--amber-bg);
}
.doneBtn {
padding: 4px 16px;
border: none;
border-radius: var(--radius-sm);
background: var(--amber);
color: #fff;
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.doneBtn:hover {
opacity: 0.85;
}
.doneBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
} }

View File

@@ -1,51 +1,204 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import styles from './DateTimePicker.module.css' import styles from './DateTimePicker.module.css'
import { forwardRef, type InputHTMLAttributes } from 'react'
interface DateTimePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> { interface DateTimePickerProps {
value?: Date value?: Date
onChange?: (date: Date | null) => void onChange?: (date: Date | null) => void
label?: string label?: string
placeholder?: string
className?: string
} }
function toLocalDateTimeString(date: Date): string { const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const pad = (n: number) => String(n).padStart(2, '0')
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate()
}
function getFirstDayOfWeek(year: number, month: number): number {
const day = new Date(year, month, 1).getDay()
return day === 0 ? 6 : day - 1 // Monday = 0
}
function formatDisplay(d: Date | undefined): string {
if (!d) return '—'
const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
return `${date}\u2009${time}`
}
function pad(n: number): string {
return String(n).padStart(2, '0')
}
export function DateTimePicker({ value, onChange, label, placeholder, className }: DateTimePickerProps) {
const [open, setOpen] = useState(false)
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth())
const [selectedDate, setSelectedDate] = useState<Date | null>(value ?? null)
const [hour, setHour] = useState(value ? pad(value.getHours()) : pad(new Date().getHours()))
const [minute, setMinute] = useState(value ? pad(value.getMinutes()) : pad(new Date().getMinutes()))
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ top: 0, left: 0 })
// Sync when value changes externally
useEffect(() => {
if (value) {
setSelectedDate(value)
setHour(pad(value.getHours()))
setMinute(pad(value.getMinutes()))
setViewYear(value.getFullYear())
setViewMonth(value.getMonth())
}
}, [value])
const reposition = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
setPos({
top: rect.bottom + 4,
left: rect.left,
})
}, [])
useEffect(() => {
if (open) {
const id = requestAnimationFrame(reposition)
return () => cancelAnimationFrame(id)
}
}, [open, reposition])
// Close on Escape only — panel closes via Apply/Now buttons
useEffect(() => {
if (!open) return
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open])
function handleDone() {
if (selectedDate) {
const d = new Date(selectedDate)
d.setHours(parseInt(hour, 10) || 0, parseInt(minute, 10) || 0, 0, 0)
onChange?.(d)
}
setOpen(false)
}
function handleDayClick(day: number) {
const d = new Date(viewYear, viewMonth, day)
setSelectedDate(d)
}
function handleNow() {
const now = new Date()
onChange?.(now)
setOpen(false)
}
function prevMonth() {
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1) }
else setViewMonth((m) => m - 1)
}
function nextMonth() {
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1) }
else setViewMonth((m) => m + 1)
}
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
const firstDay = getFirstDayOfWeek(viewYear, viewMonth)
const today = new Date()
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
return ( return (
date.getFullYear() + <div className={`${styles.wrapper} ${className ?? ''}`}>
'-' + {label && <span className={styles.label}>{label}</span>}
pad(date.getMonth() + 1) + <button
'-' + ref={triggerRef}
pad(date.getDate()) + type="button"
'T' + className={styles.trigger}
pad(date.getHours()) + onClick={() => setOpen(!open)}
':' + >
pad(date.getMinutes()) {value ? formatDisplay(value) : (placeholder ?? '—')}
</button>
{open && createPortal(
<div
ref={panelRef}
className={styles.panel}
style={{ top: pos.top, left: pos.left }}
>
{/* Month navigation */}
<div className={styles.monthNav}>
<button type="button" className={styles.navBtn} onClick={prevMonth} aria-label="Previous month">&#9664;</button>
<span className={styles.monthLabel}>{monthLabel}</span>
<button type="button" className={styles.navBtn} onClick={nextMonth} aria-label="Next month">&#9654;</button>
</div>
{/* Calendar grid */}
<div className={styles.calendar}>
{DAYS.map((d) => (
<span key={d} className={styles.dayHeader}>{d}</span>
))}
{Array.from({ length: firstDay }, (_, i) => (
<span key={`pad-${i}`} className={styles.dayEmpty} />
))}
{Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const isToday = viewYear === today.getFullYear() && viewMonth === today.getMonth() && day === today.getDate()
const isSelected = selectedDate && viewYear === selectedDate.getFullYear() && viewMonth === selectedDate.getMonth() && day === selectedDate.getDate()
return (
<button
key={day}
type="button"
className={[styles.day, isToday ? styles.dayToday : '', isSelected ? styles.daySelected : ''].filter(Boolean).join(' ')}
onClick={() => handleDayClick(day)}
>
{day}
</button>
)
})}
</div>
{/* Time selector */}
<div className={styles.timeRow}>
<span className={styles.timeLabel}>Time</span>
<input
type="text"
className={styles.timeInput}
value={hour}
onChange={(e) => setHour(e.target.value.replace(/\D/g, '').slice(0, 2))}
maxLength={2}
aria-label="Hour"
/>
<span className={styles.timeSep}>:</span>
<input
type="text"
className={styles.timeInput}
value={minute}
onChange={(e) => setMinute(e.target.value.replace(/\D/g, '').slice(0, 2))}
maxLength={2}
aria-label="Minute"
/>
</div>
{/* Actions */}
<div className={styles.actions}>
<button type="button" className={styles.todayBtn} onClick={handleNow}>Now</button>
<button type="button" className={styles.doneBtn} onClick={handleDone} disabled={!selectedDate}>Apply</button>
</div>
</div>,
document.body,
)}
</div>
) )
} }
export const DateTimePicker = forwardRef<HTMLInputElement, DateTimePickerProps>(
({ value, onChange, label, className, ...rest }, ref) => {
const inputValue = value ? toLocalDateTimeString(value) : ''
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!onChange) return
const v = e.target.value
onChange(v ? new Date(v) : null)
}
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{label && <label className={styles.label}>{label}</label>}
<input
ref={ref}
type="datetime-local"
className={styles.input}
value={inputValue}
onChange={handleChange}
{...rest}
/>
</div>
)
},
)
DateTimePicker.displayName = 'DateTimePicker' DateTimePicker.displayName = 'DateTimePicker'

View File

@@ -35,6 +35,14 @@
flex-shrink: 0; flex-shrink: 0;
} }
.dotMuted {
opacity: 0.4;
}
.activeColored {
font-weight: 600;
}
.label { .label {
line-height: 1; line-height: 1;
} }

View File

@@ -7,6 +7,7 @@ interface FilterPillProps {
active?: boolean active?: boolean
dot?: boolean dot?: boolean
dotColor?: string dotColor?: string
activeColor?: string
onClick?: () => void onClick?: () => void
className?: string className?: string
} }
@@ -18,21 +19,27 @@ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
active = false, active = false,
dot = false, dot = false,
dotColor, dotColor,
activeColor,
onClick, onClick,
className, className,
}, ref) => { }, ref) => {
const classes = [ const classes = [
styles.pill, styles.pill,
active ? styles.active : '', active ? styles.active : '',
active && activeColor ? styles.activeColored : '',
className ?? '', className ?? '',
].filter(Boolean).join(' ') ].filter(Boolean).join(' ')
const activeStyle = active && activeColor
? { borderColor: activeColor, backgroundColor: `color-mix(in srgb, ${activeColor} 12%, transparent)`, color: activeColor } as React.CSSProperties
: undefined
return ( return (
<button ref={ref} className={classes} onClick={onClick} type="button" data-active={active || undefined}> <button ref={ref} className={classes} style={activeStyle} onClick={onClick} type="button" data-active={active || undefined}>
{dot && ( {dot && (
<span <span
className={styles.dot} className={`${styles.dot} ${!active ? styles.dotMuted : ''}`}
style={dotColor ? { background: dotColor } : undefined} style={dotColor ? { background: active ? dotColor : undefined } : undefined}
/> />
)} )}
<span className={styles.label}>{label}</span> <span className={styles.label}>{label}</span>

View File

@@ -0,0 +1,59 @@
.display {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.display:hover .editBtn {
opacity: 1;
}
.disabled {
cursor: default;
}
.value {
font-family: var(--font-body);
font-size: 14px;
color: var(--text-primary);
}
.placeholder {
font-family: var(--font-body);
font-size: 14px;
color: var(--text-faint);
font-style: italic;
}
.editBtn {
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
font-size: 13px;
padding: 0 2px;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
line-height: 1;
}
.editBtn:hover {
color: var(--text-primary);
}
.disabled .editBtn {
display: none;
}
.input {
font-family: var(--font-body);
font-size: 14px;
color: var(--text-primary);
background: var(--bg-raised);
border: 1px solid var(--amber);
border-radius: var(--radius-sm);
padding: 2px 8px;
outline: none;
box-shadow: 0 0 0 3px var(--amber-bg);
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InlineEdit } from './InlineEdit'
describe('InlineEdit', () => {
it('renders value in display mode', () => {
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
expect(screen.getByText('Alice')).toBeInTheDocument()
})
it('shows placeholder when value is empty', () => {
render(<InlineEdit value="" onSave={vi.fn()} placeholder="Enter name" />)
expect(screen.getByText('Enter name')).toBeInTheDocument()
})
it('enters edit mode on click', async () => {
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
await user.click(screen.getByText('Alice'))
expect(screen.getByRole('textbox')).toHaveValue('Alice')
})
it('saves on Enter', async () => {
const onSave = vi.fn()
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={onSave} />)
await user.click(screen.getByText('Alice'))
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'Bob')
await user.keyboard('{Enter}')
expect(onSave).toHaveBeenCalledWith('Bob')
})
it('cancels on Escape', async () => {
const onSave = vi.fn()
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={onSave} />)
await user.click(screen.getByText('Alice'))
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'Bob')
await user.keyboard('{Escape}')
expect(onSave).not.toHaveBeenCalled()
expect(screen.getByText('Alice')).toBeInTheDocument()
})
it('cancels on blur', async () => {
const onSave = vi.fn()
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={onSave} />)
await user.click(screen.getByText('Alice'))
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'Bob')
await user.tab()
expect(onSave).not.toHaveBeenCalled()
})
it('does not enter edit mode when disabled', async () => {
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={vi.fn()} disabled />)
await user.click(screen.getByText('Alice'))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('shows edit icon button', () => {
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
})
it('enters edit mode when edit button is clicked', async () => {
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByRole('textbox')).toHaveValue('Alice')
})
})

View File

@@ -0,0 +1,78 @@
import { useState, useRef, useEffect } from 'react'
import styles from './InlineEdit.module.css'
export interface InlineEditProps {
value: string
onSave: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
}
export function InlineEdit({ value, onSave, placeholder, disabled, className }: InlineEditProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(value)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editing) {
inputRef.current?.focus()
inputRef.current?.select()
}
}, [editing])
function startEdit() {
if (disabled) return
setDraft(value)
setEditing(true)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
setEditing(false)
onSave(draft)
} else if (e.key === 'Escape') {
setEditing(false)
}
}
function handleBlur() {
setEditing(false)
}
if (editing) {
return (
<input
ref={inputRef}
className={`${styles.input} ${className ?? ''}`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
)
}
const isEmpty = !value
return (
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
<span
className={isEmpty ? styles.placeholder : styles.value}
onClick={startEdit}
>
{isEmpty ? placeholder : value}
</span>
{!disabled && (
<button
className={styles.editBtn}
onClick={startEdit}
aria-label="Edit"
type="button"
>
</button>
)}
</span>
)
}

View File

@@ -1,10 +1,11 @@
import styles from './StatCard.module.css' import styles from './StatCard.module.css'
import { Sparkline } from '../Sparkline/Sparkline' import { Sparkline } from '../Sparkline/Sparkline'
import type { ReactNode } from 'react'
interface StatCardProps { interface StatCardProps {
label: string label: string
value: string | number value: ReactNode
detail?: string detail?: ReactNode
trend?: 'up' | 'down' | 'neutral' trend?: 'up' | 'down' | 'neutral'
trendValue?: string trendValue?: string
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' accent?: 'amber' | 'success' | 'warning' | 'error' | 'running'

View File

@@ -1,78 +1,11 @@
/* ── Integrated readout cell ────────────────────────────── .rangeRow {
First child of the ButtonGroup — styled as a recessed
instrument-panel display, not a clickable control. */
.readout {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 4px 12px; gap: 6px;
height: 28px;
border: 1px solid var(--border);
background: var(--bg-inset);
box-shadow: inset 0 1px 3px rgba(44, 37, 32, 0.06);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--text-secondary);
white-space: nowrap;
cursor: default;
user-select: none;
} }
[data-theme="dark"] .readout { .rangeSep {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* ── Custom date picker panel ────────────────────────── */
.panel {
position: absolute;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
min-width: 220px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 500;
animation: panelIn 150ms ease-out;
}
@keyframes panelIn {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
.applyBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 5px 14px;
border: none;
border-radius: var(--radius-sm);
background: var(--amber);
color: #fff;
font-family: var(--font-body);
font-size: 12px; font-size: 12px;
font-weight: 600; color: var(--text-faint);
cursor: pointer; flex-shrink: 0;
transition: opacity 0.15s;
}
.applyBtn:hover {
opacity: 0.85;
}
.applyBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
} }

View File

@@ -1,41 +1,10 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import styles from './TimeRangeDropdown.module.css' import styles from './TimeRangeDropdown.module.css'
import { FilterPill } from '../FilterPill/FilterPill' import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs'
import { ButtonGroup } from '../ButtonGroup/ButtonGroup'
import { DateTimePicker } from '../DateTimePicker/DateTimePicker' import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { computePresetRange } from '../../utils/timePresets' import { computePresetRange } from '../../utils/timePresets'
import type { TimeRange } from '../../providers/GlobalFilterProvider' import type { TimeRange } from '../../providers/GlobalFilterProvider'
function formatRangeLabel(range: TimeRange): string {
const start = range.preset ? computePresetRange(range.preset).start : range.start
const time = (d: Date) =>
d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
const dateTime = (d: Date) =>
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + '\u2009' + time(d)
// Preset ranges are open-ended ("since X"), so only show the start
if (range.preset) {
const now = new Date()
const sameDay =
start.getFullYear() === now.getFullYear() &&
start.getMonth() === now.getMonth() &&
start.getDate() === now.getDate()
return sameDay ? `${time(start)}\u2009\u2013\u2009now` : `${dateTime(start)}\u2009\u2013\u2009now`
}
// Custom range: show both ends
const end = range.end
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
if (sameDay) return `${time(start)}\u2009\u2013\u2009${time(end)}`
return `${dateTime(start)}\u2009\u2013\u2009${dateTime(end)}`
}
const PRESETS = [ const PRESETS = [
{ value: 'last-1h', label: '1h' }, { value: 'last-1h', label: '1h' },
{ value: 'last-3h', label: '3h' }, { value: 'last-3h', label: '3h' },
@@ -45,6 +14,8 @@ const PRESETS = [
{ value: 'last-7d', label: '7d' }, { value: 'last-7d', label: '7d' },
] ]
const CUSTOM_VALUE = '__custom__'
interface TimeRangeDropdownProps { interface TimeRangeDropdownProps {
value: TimeRange value: TimeRange
onChange: (range: TimeRange) => void onChange: (range: TimeRange) => void
@@ -52,112 +23,78 @@ interface TimeRangeDropdownProps {
} }
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) { export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
const [open, setOpen] = useState(false) const [customFrom, setCustomFrom] = useState<Date>(value.start)
const [customFrom, setCustomFrom] = useState<Date | null>(value.start) const [customTo, setCustomTo] = useState<Date>(value.end)
const [customTo, setCustomTo] = useState<Date | null>(value.end) const [toIsSet, setToIsSet] = useState(false)
const customRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 })
const isCustom = value.preset === null || value.preset === 'custom' const isCustom = value.preset === null || value.preset === 'custom'
const activeValue = isCustom ? CUSTOM_VALUE : (value.preset ?? 'last-1h')
const reposition = useCallback(() => { // Sync local state when value changes from presets
if (!customRef.current) return
const rect = customRef.current.getBoundingClientRect()
const panelWidth = panelRef.current?.offsetWidth ?? 240
setPanelPos({
top: rect.bottom + window.scrollY + 8,
left: rect.right + window.scrollX - panelWidth,
})
}, [])
useEffect(() => { useEffect(() => {
if (open) { setCustomFrom(value.start)
const id = requestAnimationFrame(reposition) setCustomTo(value.end)
return () => cancelAnimationFrame(id) }, [value.start, value.end])
}
}, [open, reposition])
useEffect(() => { function handleTabChange(tabValue: string) {
if (!open) return if (tabValue === CUSTOM_VALUE) return
function handleMouseDown(e: MouseEvent) { setToIsSet(false)
if ( const range = computePresetRange(tabValue)
customRef.current?.contains(e.target as Node) || onChange({ ...range, preset: tabValue })
panelRef.current?.contains(e.target as Node) }
) return
setOpen(false)
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleMouseDown)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handleMouseDown)
document.removeEventListener('keydown', handleKey)
}
}, [open])
const rangeLabel = useMemo(() => formatRangeLabel(value), [value]) function handleFromChange(d: Date | null) {
if (!d) return
setCustomFrom(d)
// Only set preset to null; keep to-date as "now" if not explicitly set
if (toIsSet) {
onChange({ start: d, end: customTo, preset: null })
} else {
onChange({ start: d, end: new Date(), preset: null })
}
}
function handleToChange(d: Date | null) {
if (!d) return
setCustomTo(d)
setToIsSet(true)
onChange({ start: customFrom, end: d, preset: null })
}
// Show "now" when to-date is not explicitly set
const showNow = !isCustom || !toIsSet
const rangeContent = (
<div className={styles.rangeRow}>
<DateTimePicker
value={isCustom ? customFrom : value.start}
onChange={handleFromChange}
/>
<span className={styles.rangeSep}></span>
{showNow ? (
<DateTimePicker
value={undefined}
onChange={handleToChange}
placeholder="now"
/>
) : (
<DateTimePicker
value={customTo}
onChange={handleToChange}
/>
)}
</div>
)
return ( return (
<> <div className={className}>
<ButtonGroup className={className}> <SegmentedTabs
{PRESETS.map((preset) => ( tabs={PRESETS}
<FilterPill active={activeValue}
key={preset.value} onChange={handleTabChange}
label={preset.label} trailing={rangeContent}
active={value.preset === preset.value} trailingValue={CUSTOM_VALUE}
onClick={() => { />
setOpen(false) </div>
const range = computePresetRange(preset.value)
onChange({ ...range, preset: preset.value })
}}
/>
))}
<FilterPill
ref={customRef}
label="Custom"
active={isCustom}
onClick={() => setOpen((prev) => !prev)}
/>
<span className={styles.readout} aria-label="Active time range">
{rangeLabel}
</span>
</ButtonGroup>
{open && createPortal(
<div
ref={panelRef}
className={styles.panel}
style={{ top: panelPos.top, left: panelPos.left }}
role="dialog"
>
<DateTimePicker
label="From"
value={customFrom ?? undefined}
onChange={(d) => setCustomFrom(d)}
/>
<DateTimePicker
label="To"
value={customTo ?? undefined}
onChange={(d) => setCustomTo(d)}
/>
<button
type="button"
className={styles.applyBtn}
disabled={!customFrom || !customTo}
onClick={() => {
if (customFrom && customTo) {
onChange({ start: customFrom, end: customTo, preset: null })
setOpen(false)
}
}}
>
Apply
</button>
</div>,
document.body,
)}
</>
) )
} }

View File

@@ -3,6 +3,7 @@ export { Avatar } from './Avatar/Avatar'
export { Badge } from './Badge/Badge' export { Badge } from './Badge/Badge'
export { Button } from './Button/Button' export { Button } from './Button/Button'
export { ButtonGroup } from './ButtonGroup/ButtonGroup' export { ButtonGroup } from './ButtonGroup/ButtonGroup'
export type { ButtonGroupItem } from './ButtonGroup/ButtonGroup'
export { Card } from './Card/Card' export { Card } from './Card/Card'
export { Checkbox } from './Checkbox/Checkbox' export { Checkbox } from './Checkbox/Checkbox'
export { CodeBlock } from './CodeBlock/CodeBlock' export { CodeBlock } from './CodeBlock/CodeBlock'
@@ -13,6 +14,8 @@ export { EmptyState } from './EmptyState/EmptyState'
export { FilterPill } from './FilterPill/FilterPill' export { FilterPill } from './FilterPill/FilterPill'
export { FormField } from './FormField/FormField' export { FormField } from './FormField/FormField'
export { InfoCallout } from './InfoCallout/InfoCallout' export { InfoCallout } from './InfoCallout/InfoCallout'
export { InlineEdit } from './InlineEdit/InlineEdit'
export type { InlineEditProps } from './InlineEdit/InlineEdit'
export { Input } from './Input/Input' export { Input } from './Input/Input'
export { KeyboardHint } from './KeyboardHint/KeyboardHint' export { KeyboardHint } from './KeyboardHint/KeyboardHint'
export { Label } from './Label/Label' export { Label } from './Label/Label'

View File

@@ -10,8 +10,8 @@
--sidebar-bg: #2C2520; --sidebar-bg: #2C2520;
--sidebar-hover: #3A322C; --sidebar-hover: #3A322C;
--sidebar-active: #4A3F38; --sidebar-active: #4A3F38;
--sidebar-text: #BFB5A8; --sidebar-text: #D8D0C6;
--sidebar-muted: #7A6F63; --sidebar-muted: #9C9184;
/* Text */ /* Text */
--text-primary: #1A1612; --text-primary: #1A1612;
@@ -58,6 +58,10 @@
--shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10); --shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10);
--shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04); --shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04);
/* Accent: purple (for choice/router elements) */
--purple: #7C3AED;
--purple-bg: #F3EEFA;
/* Chart palette */ /* Chart palette */
--chart-1: #C6820E; --chart-1: #C6820E;
--chart-2: #3D7C47; --chart-2: #3D7C47;
@@ -80,7 +84,7 @@
--sidebar-bg: #141210; --sidebar-bg: #141210;
--sidebar-hover: #1E1B17; --sidebar-hover: #1E1B17;
--sidebar-active: #28241E; --sidebar-active: #28241E;
--sidebar-text: #A89E92; --sidebar-text: #CCC4B8;
--sidebar-muted: #6A6058; --sidebar-muted: #6A6058;
--text-primary: #E8E0D6; --text-primary: #E8E0D6;
@@ -109,6 +113,9 @@
--running-bg: #1A2628; --running-bg: #1A2628;
--running-border: #243A3E; --running-border: #243A3E;
--purple: #A78BFA;
--purple-bg: rgba(124, 58, 237, 0.15);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3); --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4); --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);

View File

@@ -12,7 +12,6 @@ export const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' }, { label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' }, { label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' }, { label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' }, { label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' }, { label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' }, { label: 'Custom', value: 'custom' },
@@ -23,7 +22,6 @@ export const PRESET_SHORT_LABELS: Record<string, string> = {
'last-3h': '3h', 'last-3h': '3h',
'last-6h': '6h', 'last-6h': '6h',
'today': 'Today', 'today': 'Today',
'shift': 'Shift',
'last-24h': '24h', 'last-24h': '24h',
'last-7d': '7d', 'last-7d': '7d',
'custom': 'Custom', 'custom': 'Custom',
@@ -45,10 +43,6 @@ export function computePresetRange(preset: string): DateRange {
start.setHours(0, 0, 0, 0) start.setHours(0, 0, 0, 0)
return { start, end } return { start, end }
} }
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h': case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end } return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d': case 'last-7d':

View File

@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from './design-system/providers/ThemeProvider' import { ThemeProvider } from './design-system/providers/ThemeProvider'
import { GlobalFilterProvider } from './design-system/providers/GlobalFilterProvider' import { GlobalFilterProvider } from './design-system/providers/GlobalFilterProvider'
import { CommandPaletteProvider } from './design-system/providers/CommandPaletteProvider' import { CommandPaletteProvider } from './design-system/providers/CommandPaletteProvider'
import { ToastProvider } from './design-system/composites/Toast/Toast'
import App from './App' import App from './App'
import './index.css' import './index.css'
@@ -13,7 +14,9 @@ createRoot(document.getElementById('root')!).render(
<ThemeProvider> <ThemeProvider>
<GlobalFilterProvider> <GlobalFilterProvider>
<CommandPaletteProvider> <CommandPaletteProvider>
<App /> <ToastProvider>
<App />
</ToastProvider>
</CommandPaletteProvider> </CommandPaletteProvider>
</GlobalFilterProvider> </GlobalFilterProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -20,6 +20,7 @@ export interface Exchange {
errorMessage?: string errorMessage?: string
errorClass?: string errorClass?: string
processors: ProcessorData[] processors: ProcessorData[]
correlationGroup?: string
} }
export const exchanges: Exchange[] = [ export const exchanges: Exchange[] = [
@@ -34,6 +35,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:12:04'), timestamp: new Date('2026-03-18T09:12:04'),
correlationId: 'cmr-f4a1c82b-9d3e', correlationId: 'cmr-f4a1c82b-9d3e',
agent: 'prod-1', agent: 'prod-1',
correlationGroup: 'order-flow-001',
processors: [ processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 }, { name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 }, { name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
@@ -53,6 +55,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:11:22'), timestamp: new Date('2026-03-18T09:11:22'),
correlationId: 'cmr-7b2d9f14-c5a8', correlationId: 'cmr-7b2d9f14-c5a8',
agent: 'prod-2', agent: 'prod-2',
correlationGroup: 'payment-flow-001',
processors: [ processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 }, { name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 5 }, { name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 5 },
@@ -72,6 +75,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:13:44'), timestamp: new Date('2026-03-18T09:13:44'),
correlationId: 'cmr-3c8e1a7f-d2b6', correlationId: 'cmr-3c8e1a7f-d2b6',
agent: 'prod-1', agent: 'prod-1',
correlationGroup: 'order-flow-001',
processors: [ processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 }, { name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'enrich(inventory-api)', type: 'enrich', durationMs: 29990, status: 'slow', startMs: 5 }, { name: 'enrich(inventory-api)', type: 'enrich', durationMs: 29990, status: 'slow', startMs: 5 },
@@ -88,6 +92,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:09:47'), timestamp: new Date('2026-03-18T09:09:47'),
correlationId: 'cmr-a9f3b2c1-e4d7', correlationId: 'cmr-a9f3b2c1-e4d7',
agent: 'prod-3', agent: 'prod-3',
correlationGroup: 'shipment-flow-001',
processors: [ processors: [
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 6, status: 'ok', startMs: 0 }, { name: 'from(jms:shipments)', type: 'consumer', durationMs: 6, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 6 }, { name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 6 },
@@ -106,6 +111,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:06:11'), timestamp: new Date('2026-03-18T09:06:11'),
correlationId: 'cmr-9a4f2b71-e8c3', correlationId: 'cmr-9a4f2b71-e8c3',
agent: 'prod-2', agent: 'prod-2',
correlationGroup: 'payment-flow-002',
errorMessage: 'org.apache.camel.CamelExecutionException: Payment gateway timeout after 5000ms — POST https://pay.provider.com/v2/charge returned HTTP 504. Retry exhausted (3/3).', errorMessage: 'org.apache.camel.CamelExecutionException: Payment gateway timeout after 5000ms — POST https://pay.provider.com/v2/charge returned HTTP 504. Retry exhausted (3/3).',
errorClass: 'org.apache.camel.CamelExecutionException', errorClass: 'org.apache.camel.CamelExecutionException',
processors: [ processors: [
@@ -145,6 +151,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:00:15'), timestamp: new Date('2026-03-18T09:00:15'),
correlationId: 'cmr-2e5f8d9a-b4c1', correlationId: 'cmr-2e5f8d9a-b4c1',
agent: 'prod-3', agent: 'prod-3',
correlationGroup: 'order-flow-001',
processors: [ processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 }, { name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 5, status: 'ok', startMs: 3 }, { name: 'unmarshal(json)', type: 'transform', durationMs: 5, status: 'ok', startMs: 3 },
@@ -164,6 +171,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:58:33'), timestamp: new Date('2026-03-18T08:58:33'),
correlationId: 'cmr-d1a3e7f4-c2b8', correlationId: 'cmr-d1a3e7f4-c2b8',
agent: 'prod-1', agent: 'prod-1',
correlationGroup: 'payment-flow-001',
processors: [ processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 }, { name: 'from(jms:payments)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ name: 'validate(payment-schema)', type: 'process', durationMs: 14, status: 'ok', startMs: 4 }, { name: 'validate(payment-schema)', type: 'process', durationMs: 14, status: 'ok', startMs: 4 },
@@ -199,6 +207,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:50:41'), timestamp: new Date('2026-03-18T08:50:41'),
correlationId: 'cmr-f3c7a1b9-d5e2', correlationId: 'cmr-f3c7a1b9-d5e2',
agent: 'prod-1', agent: 'prod-1',
correlationGroup: 'order-flow-001',
processors: [ processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 }, { name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 3 }, { name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 3 },
@@ -218,6 +227,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:46:19'), timestamp: new Date('2026-03-18T08:46:19'),
correlationId: 'cmr-a2d8f5c3-b9e1', correlationId: 'cmr-a2d8f5c3-b9e1',
agent: 'prod-2', agent: 'prod-2',
correlationGroup: 'payment-flow-001',
processors: [ processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 }, { name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'validate(payment-schema)', type: 'process', durationMs: 12, status: 'ok', startMs: 5 }, { name: 'validate(payment-schema)', type: 'process', durationMs: 12, status: 'ok', startMs: 5 },
@@ -254,6 +264,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:31:05'), timestamp: new Date('2026-03-18T08:31:05'),
correlationId: 'cmr-7e9a2c5f-d1b4', correlationId: 'cmr-7e9a2c5f-d1b4',
agent: 'prod-2', agent: 'prod-2',
correlationGroup: 'payment-flow-002',
errorMessage: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP operation failed invoking https://pay.provider.com/v2/charge with statusCode: 422 — Unprocessable Entity: card declined (insufficient funds)', errorMessage: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP operation failed invoking https://pay.provider.com/v2/charge with statusCode: 422 — Unprocessable Entity: card declined (insufficient funds)',
errorClass: 'org.apache.camel.component.http.HttpOperationFailedException', errorClass: 'org.apache.camel.component.http.HttpOperationFailedException',
processors: [ processors: [
@@ -273,6 +284,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:22:44'), timestamp: new Date('2026-03-18T08:22:44'),
correlationId: 'cmr-b5c8d2a7-f4e3', correlationId: 'cmr-b5c8d2a7-f4e3',
agent: 'prod-3', agent: 'prod-3',
correlationGroup: 'shipment-flow-001',
processors: [ processors: [
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 }, { name: 'from(jms:shipments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 5 }, { name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 5 },
@@ -291,6 +303,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:15:19'), timestamp: new Date('2026-03-18T08:15:19'),
correlationId: 'cmr-d9e3f7b1-a6c5', correlationId: 'cmr-d9e3f7b1-a6c5',
agent: 'prod-4', agent: 'prod-4',
correlationGroup: 'order-flow-001',
processors: [ processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 }, { name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 4 }, { name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 4 },

View File

@@ -20,7 +20,7 @@ export interface MetricSeries {
data: TimeSeriesPoint[] data: TimeSeriesPoint[]
} }
// Generate a realistic time series for the past shift (06:00 - now ~09:15) // Generate a realistic time series for the past hours (06:00 - now ~09:15)
function generateTimeSeries( function generateTimeSeries(
baseValue: number, baseValue: number,
variance: number, variance: number,
@@ -44,12 +44,12 @@ function generateTimeSeries(
// KPI stat cards data // KPI stat cards data
export const kpiMetrics: KpiMetric[] = [ export const kpiMetrics: KpiMetric[] = [
{ {
label: 'Exchanges (shift)', label: 'Exchanges',
value: '3,241', value: '3,241',
trend: 'up', trend: 'up',
trendValue: '+12%', trendValue: '+12%',
trendSentiment: 'good', trendSentiment: 'good',
detail: '97.1% success since 06:00', detail: '97.1% success rate',
accent: 'amber', accent: 'amber',
sparkline: [28, 32, 29, 35, 38, 41, 37, 44, 42, 47, 45, 51, 48, 52], sparkline: [28, 32, 29, 35, 38, 41, 37, 44, 42, 47, 45, 51, 48, 52],
}, },
@@ -64,12 +64,12 @@ export const kpiMetrics: KpiMetric[] = [
sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1], sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1],
}, },
{ {
label: 'Errors (shift)', label: 'Errors',
value: 38, value: 38,
trend: 'up', trend: 'up',
trendValue: '+5', trendValue: '+5',
trendSentiment: 'bad', trendSentiment: 'bad',
detail: '23 overnight · 15 since 06:00', detail: '38 errors in selected period',
accent: 'error', accent: 'error',
sparkline: [1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6, 8], sparkline: [1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6, 8],
}, },
@@ -147,6 +147,7 @@ export const errorCountSeries: MetricSeries[] = [
export interface RouteMetricRow { export interface RouteMetricRow {
routeId: string routeId: string
routeName: string routeName: string
appId: string
exchangeCount: number exchangeCount: number
successRate: number successRate: number
avgDurationMs: number avgDurationMs: number
@@ -159,6 +160,7 @@ export const routeMetrics: RouteMetricRow[] = [
{ {
routeId: 'order-intake', routeId: 'order-intake',
routeName: 'order-intake', routeName: 'order-intake',
appId: 'order-service',
exchangeCount: 892, exchangeCount: 892,
successRate: 99.2, successRate: 99.2,
avgDurationMs: 88, avgDurationMs: 88,
@@ -169,6 +171,7 @@ export const routeMetrics: RouteMetricRow[] = [
{ {
routeId: 'order-enrichment', routeId: 'order-enrichment',
routeName: 'order-enrichment', routeName: 'order-enrichment',
appId: 'order-service',
exchangeCount: 541, exchangeCount: 541,
successRate: 97.6, successRate: 97.6,
avgDurationMs: 156, avgDurationMs: 156,
@@ -179,6 +182,7 @@ export const routeMetrics: RouteMetricRow[] = [
{ {
routeId: 'payment-process', routeId: 'payment-process',
routeName: 'payment-process', routeName: 'payment-process',
appId: 'payment-svc',
exchangeCount: 414, exchangeCount: 414,
successRate: 96.1, successRate: 96.1,
avgDurationMs: 234, avgDurationMs: 234,
@@ -186,9 +190,21 @@ export const routeMetrics: RouteMetricRow[] = [
errorCount: 16, errorCount: 16,
sparkline: [210, 225, 232, 218, 241, 235, 228, 242, 238, 231, 244, 237, 233, 234], sparkline: [210, 225, 232, 218, 241, 235, 228, 242, 238, 231, 244, 237, 233, 234],
}, },
{
routeId: 'payment-validate',
routeName: 'payment-validate',
appId: 'payment-svc',
exchangeCount: 498,
successRate: 99.8,
avgDurationMs: 142,
p99DurationMs: 198,
errorCount: 1,
sparkline: [138, 141, 140, 143, 145, 142, 144, 141, 139, 143, 142, 140, 141, 142],
},
{ {
routeId: 'shipment-dispatch', routeId: 'shipment-dispatch',
routeName: 'shipment-dispatch', routeName: 'shipment-dispatch',
appId: 'shipment-tracker',
exchangeCount: 387, exchangeCount: 387,
successRate: 98.4, successRate: 98.4,
avgDurationMs: 118, avgDurationMs: 118,
@@ -196,4 +212,26 @@ export const routeMetrics: RouteMetricRow[] = [
errorCount: 6, errorCount: 6,
sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118], sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118],
}, },
{
routeId: 'shipment-track',
routeName: 'shipment-track',
appId: 'shipment-tracker',
exchangeCount: 923,
successRate: 99.5,
avgDurationMs: 94,
p99DurationMs: 167,
errorCount: 5,
sparkline: [88, 91, 93, 95, 92, 94, 96, 93, 91, 95, 94, 92, 93, 94],
},
{
routeId: 'notification-dispatch',
routeName: 'notification-dispatch',
appId: 'notification-hub',
exchangeCount: 471,
successRate: 98.9,
avgDurationMs: 62,
p99DurationMs: 124,
errorCount: 5,
sparkline: [58, 60, 63, 61, 64, 62, 60, 63, 65, 62, 61, 63, 62, 62],
},
] ]

View File

@@ -2,7 +2,7 @@ import type { SearchResult } from '../design-system/composites/CommandPalette/ty
import { exchanges, type Exchange } from './exchanges' import { exchanges, type Exchange } from './exchanges'
import { routes } from './routes' import { routes } from './routes'
import { agents } from './agents' import { agents } from './agents'
import { SIDEBAR_APPS, type SidebarApp } from './sidebar' import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s` if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
@@ -72,14 +72,16 @@ export function buildSearchData(
}) })
} }
const routeToApp = buildRouteToAppMap(apps)
for (const route of rts) { for (const route of rts) {
const appIdForRoute = routeToApp.get(route.id)
results.push({ results.push({
id: route.id, id: route.id,
category: 'route', category: 'route',
title: route.name, title: route.name,
badges: [{ label: route.group }], badges: [{ label: route.group }],
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`, meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
path: `/routes/${route.id}`, path: appIdForRoute ? `/apps/${appIdForRoute}/${route.id}` : `/apps/${route.id}`,
}) })
} }

View File

@@ -20,6 +20,17 @@ export interface SidebarApp {
agents: SidebarAgent[] agents: SidebarAgent[]
} }
/** Build a routeId → appId lookup from the sidebar tree */
export function buildRouteToAppMap(apps: SidebarApp[] = SIDEBAR_APPS): Map<string, string> {
const map = new Map<string, string>()
for (const app of apps) {
for (const route of app.routes) {
map.set(route.id, app.id)
}
}
return map
}
export const SIDEBAR_APPS: SidebarApp[] = [ export const SIDEBAR_APPS: SidebarApp[] = [
{ {
id: 'order-service', id: 'order-service',

View File

@@ -0,0 +1,5 @@
.adminContent {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
}

View File

@@ -1,22 +1,45 @@
import { useNavigate, useLocation } from 'react-router-dom'
import { AppShell } from '../../design-system/layout/AppShell/AppShell' import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar' import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState' import { Tabs } from '../../design-system/composites/Tabs/Tabs'
import { SIDEBAR_APPS } from '../../mocks/sidebar' import { SIDEBAR_APPS } from '../../mocks/sidebar'
import styles from './Admin.module.css'
import type { ReactNode } from 'react'
const ADMIN_TABS = [
{ label: 'User Management', value: '/admin/rbac' },
{ label: 'Audit Log', value: '/admin/audit' },
{ label: 'OIDC', value: '/admin/oidc' },
]
interface AdminLayoutProps {
title: string
children: ReactNode
}
export function AdminLayout({ title, children }: AdminLayoutProps) {
const navigate = useNavigate()
const location = useLocation()
export function Admin() {
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar <TopBar
breadcrumb={[{ label: 'Admin' }]} breadcrumb={[
{ label: 'Admin', href: '/admin' },
{ label: title },
]}
environment="PRODUCTION" environment="PRODUCTION"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<EmptyState <Tabs
title="Admin Panel" tabs={ADMIN_TABS}
description="Admin panel coming soon." active={location.pathname}
onChange={(path) => navigate(path)}
/> />
<div className={styles.adminContent}>
{children}
</div>
</AppShell> </AppShell>
) )
} }

View File

@@ -0,0 +1,86 @@
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filterInput {
width: 200px;
}
.filterSelect {
width: 160px;
}
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
}
.target {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expandedDetail {
padding: 4px 0;
}
.detailGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.detailField {
display: flex;
flex-direction: column;
gap: 4px;
}
.detailLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
font-family: var(--font-body);
}
.detailValue {
font-size: 12px;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,153 @@
import { useState, useMemo } from 'react'
import { AdminLayout } from '../Admin'
import { Badge } from '../../../design-system/primitives/Badge/Badge'
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
import { Input } from '../../../design-system/primitives/Input/Input'
import { Select } from '../../../design-system/primitives/Select/Select'
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
import { DataTable } from '../../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../../design-system/composites/DataTable/types'
import type { DateRange } from '../../../design-system/utils/timePresets'
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
import styles from './AuditLog.module.css'
const CATEGORIES = [
{ value: '', label: 'All categories' },
{ value: 'INFRA', label: 'INFRA' },
{ value: 'AUTH', label: 'AUTH' },
{ value: 'USER_MGMT', label: 'USER_MGMT' },
{ value: 'CONFIG', label: 'CONFIG' },
]
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
})
}
const COLUMNS: Column<AuditEvent>[] = [
{
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
},
{
key: 'username', header: 'User', sortable: true,
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
},
{
key: 'category', header: 'Category', width: '110px', sortable: true,
render: (_, row) => <Badge label={row.category} color="auto" />,
},
{ key: 'action', header: 'Action' },
{
key: 'target', header: 'Target',
render: (_, row) => <span className={styles.target}>{row.target}</span>,
},
{
key: 'result', header: 'Result', width: '90px', sortable: true,
render: (_, row) => (
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
),
},
]
const now = Date.now()
const INITIAL_RANGE: DateRange = {
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
to: new Date(now).toISOString().slice(0, 16),
}
export function AuditLog() {
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
const [userFilter, setUserFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [searchFilter, setSearchFilter] = useState('')
const filtered = useMemo(() => {
const from = new Date(dateRange.from).getTime()
const to = new Date(dateRange.to).getTime()
return AUDIT_EVENTS.filter((e) => {
const ts = new Date(e.timestamp).getTime()
if (ts < from || ts > to) return false
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
if (categoryFilter && e.category !== categoryFilter) return false
if (searchFilter) {
const q = searchFilter.toLowerCase()
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
}
return true
})
}, [dateRange, userFilter, categoryFilter, searchFilter])
return (
<AdminLayout title="Audit Log">
<div className={styles.filters}>
<DateRangePicker
value={dateRange}
onChange={setDateRange}
/>
<Input
placeholder="Filter by user..."
value={userFilter}
onChange={(e) => setUserFilter(e.target.value)}
onClear={() => setUserFilter('')}
className={styles.filterInput}
/>
<Select
options={CATEGORIES}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className={styles.filterSelect}
/>
<Input
placeholder="Search action or target..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
onClear={() => setSearchFilter('')}
className={styles.filterInput}
/>
</div>
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Audit Log</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{filtered.length} events
</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={COLUMNS}
data={filtered}
sortable
flush
pageSize={10}
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
expandedContent={(row) => (
<div className={styles.expandedDetail}>
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<MonoText size="xs">{row.ipAddress}</MonoText>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{row.userAgent}</span>
</div>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Detail</span>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
</div>
</div>
)}
/>
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,194 @@
export interface AuditEvent {
id: string
timestamp: string
username: string
category: 'INFRA' | 'AUTH' | 'USER_MGMT' | 'CONFIG'
action: string
target: string
result: 'SUCCESS' | 'FAILURE'
detail: Record<string, unknown>
ipAddress: string
userAgent: string
}
const now = Date.now()
const hour = 3600_000
const day = 24 * hour
export const AUDIT_EVENTS: AuditEvent[] = [
{
id: 'audit-1', timestamp: new Date(now - 0.5 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
target: 'users/alice', result: 'SUCCESS',
detail: { displayName: 'Alice Johnson', roles: ['VIEWER'] },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-2', timestamp: new Date(now - 1.2 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
target: 'db/primary', result: 'SUCCESS',
detail: { oldSize: 10, newSize: 20, reason: 'auto-scale' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 'audit-3', timestamp: new Date(now - 2 * hour).toISOString(),
username: 'alice', category: 'AUTH', action: 'LOGIN',
target: 'sessions/abc123', result: 'SUCCESS',
detail: { method: 'OIDC', provider: 'keycloak' },
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
},
{
id: 'audit-4', timestamp: new Date(now - 2.5 * hour).toISOString(),
username: 'unknown', category: 'AUTH', action: 'LOGIN',
target: 'sessions', result: 'FAILURE',
detail: { method: 'local', reason: 'invalid_credentials' },
ipAddress: '203.0.113.50', userAgent: 'curl/8.1',
},
{
id: 'audit-5', timestamp: new Date(now - 3 * hour).toISOString(),
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
target: 'thresholds/pool-connections', result: 'SUCCESS',
detail: { field: 'maxConnections', oldValue: 50, newValue: 100 },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-6', timestamp: new Date(now - 4 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
target: 'users/bob', result: 'SUCCESS',
detail: { role: 'EDITOR', method: 'direct' },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-7', timestamp: new Date(now - 5 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
target: 'opensearch/exchanges', result: 'SUCCESS',
detail: { documents: 15420, duration: '12.3s' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 'audit-8', timestamp: new Date(now - 6 * hour).toISOString(),
username: 'bob', category: 'AUTH', action: 'LOGIN',
target: 'sessions/def456', result: 'SUCCESS',
detail: { method: 'local' },
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
},
{
id: 'audit-9', timestamp: new Date(now - 8 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_GROUP',
target: 'groups/developers', result: 'SUCCESS',
detail: { parent: null },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-10', timestamp: new Date(now - 10 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'BACKUP',
target: 'db/primary', result: 'SUCCESS',
detail: { sizeBytes: 524288000, duration: '45s' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 'audit-11', timestamp: new Date(now - 12 * hour).toISOString(),
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_OIDC',
target: 'config/oidc', result: 'SUCCESS',
detail: { field: 'autoSignup', oldValue: false, newValue: true },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-12', timestamp: new Date(now - 1 * day).toISOString(),
username: 'alice', category: 'AUTH', action: 'LOGOUT',
target: 'sessions/abc123', result: 'SUCCESS',
detail: { reason: 'user_initiated' },
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
},
{
id: 'audit-13', timestamp: new Date(now - 1 * day - 2 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'DELETE_USER',
target: 'users/temp-user', result: 'SUCCESS',
detail: { reason: 'cleanup' },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-14', timestamp: new Date(now - 1 * day - 4 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
target: 'db/primary', result: 'FAILURE',
detail: { oldSize: 20, newSize: 50, error: 'max_connections_exceeded' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 'audit-15', timestamp: new Date(now - 1 * day - 6 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'UPDATE_GROUP',
target: 'groups/admins', result: 'SUCCESS',
detail: { addedMembers: ['alice'], removedMembers: [] },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-16', timestamp: new Date(now - 2 * day).toISOString(),
username: 'bob', category: 'AUTH', action: 'PASSWORD_CHANGE',
target: 'users/bob', result: 'SUCCESS',
detail: { method: 'self_service' },
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
},
{
id: 'audit-17', timestamp: new Date(now - 2 * day - 3 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'VACUUM',
target: 'db/primary/exchanges', result: 'SUCCESS',
detail: { reclaimedBytes: 1048576, duration: '3.2s' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 'audit-18', timestamp: new Date(now - 2 * day - 5 * hour).toISOString(),
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
target: 'thresholds/latency-p99', result: 'SUCCESS',
detail: { field: 'warningMs', oldValue: 500, newValue: 300 },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-19', timestamp: new Date(now - 3 * day).toISOString(),
username: 'attacker', category: 'AUTH', action: 'LOGIN',
target: 'sessions', result: 'FAILURE',
detail: { method: 'local', reason: 'account_locked', attempts: 5 },
ipAddress: '198.51.100.23', userAgent: 'python-requests/2.31',
},
{
id: 'audit-20', timestamp: new Date(now - 3 * day - 2 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
target: 'groups/developers', result: 'SUCCESS',
detail: { role: 'EDITOR', method: 'group_assignment' },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-21', timestamp: new Date(now - 4 * day).toISOString(),
username: 'system', category: 'INFRA', action: 'BACKUP',
target: 'db/primary', result: 'FAILURE',
detail: { error: 'disk_full', sizeBytes: 0 },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 'audit-22', timestamp: new Date(now - 4 * day - 1 * hour).toISOString(),
username: 'alice', category: 'CONFIG', action: 'VIEW_CONFIG',
target: 'config/oidc', result: 'SUCCESS',
detail: { section: 'provider_settings' },
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
},
{
id: 'audit-23', timestamp: new Date(now - 5 * day).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_ROLE',
target: 'roles/OPERATOR', result: 'SUCCESS',
detail: { scope: 'custom', description: 'Pipeline operator' },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 'audit-24', timestamp: new Date(now - 5 * day - 3 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
target: 'opensearch/agents', result: 'SUCCESS',
detail: { documents: 230, duration: '1.1s' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 'audit-25', timestamp: new Date(now - 6 * day).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
target: 'users/bob', result: 'SUCCESS',
detail: { displayName: 'Bob Smith', roles: ['VIEWER'] },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
]

View File

@@ -0,0 +1,53 @@
.page {
max-width: 640px;
margin: 0 auto;
}
.toolbar {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-bottom: 20px;
}
.section {
margin-bottom: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.toggleRow {
display: flex;
align-items: center;
gap: 12px;
}
.hint {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-body);
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.noRoles {
font-size: 12px;
color: var(--text-faint);
font-style: italic;
font-family: var(--font-body);
}
.addRoleRow {
display: flex;
gap: 8px;
align-items: center;
}
.roleInput {
width: 200px;
}

View File

@@ -0,0 +1,188 @@
import { useState } from 'react'
import { AdminLayout } from '../Admin'
import { Button } from '../../../design-system/primitives/Button/Button'
import { Input } from '../../../design-system/primitives/Input/Input'
import { Toggle } from '../../../design-system/primitives/Toggle/Toggle'
import { FormField } from '../../../design-system/primitives/FormField/FormField'
import { Tag } from '../../../design-system/primitives/Tag/Tag'
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
import { useToast } from '../../../design-system/composites/Toast/Toast'
import styles from './OidcConfig.module.css'
interface OidcFormData {
enabled: boolean
autoSignup: boolean
issuerUri: string
clientId: string
clientSecret: string
rolesClaim: string
displayNameClaim: string
defaultRoles: string[]
}
const INITIAL_DATA: OidcFormData = {
enabled: true,
autoSignup: true,
issuerUri: 'https://keycloak.example.com/realms/cameleer',
clientId: 'cameleer-app',
clientSecret: '••••••••••••',
rolesClaim: 'realm_access.roles',
displayNameClaim: 'name',
defaultRoles: ['USER', 'VIEWER'],
}
export function OidcConfig() {
const [form, setForm] = useState<OidcFormData>(INITIAL_DATA)
const [newRole, setNewRole] = useState('')
const [deleteOpen, setDeleteOpen] = useState(false)
const { toast } = useToast()
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
setForm((prev) => ({ ...prev, [key]: value }))
}
function addRole() {
const role = newRole.trim().toUpperCase()
if (role && !form.defaultRoles.includes(role)) {
update('defaultRoles', [...form.defaultRoles, role])
setNewRole('')
}
}
function removeRole(role: string) {
update('defaultRoles', form.defaultRoles.filter((r) => r !== role))
}
function handleSave() {
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' })
}
function handleTest() {
toast({ title: 'Connection test', description: 'OIDC provider responded successfully.', variant: 'info' })
}
function handleDelete() {
setDeleteOpen(false)
setForm({ ...INITIAL_DATA, enabled: false, issuerUri: '', clientId: '', clientSecret: '', defaultRoles: [] })
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' })
}
return (
<AdminLayout title="OIDC Configuration">
<div className={styles.page}>
<div className={styles.toolbar}>
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
Test Connection
</Button>
<Button size="sm" variant="primary" onClick={handleSave}>
Save
</Button>
</div>
<section className={styles.section}>
<SectionHeader>Behavior</SectionHeader>
<div className={styles.toggleRow}>
<Toggle
label="Enabled"
checked={form.enabled}
onChange={(e) => update('enabled', e.target.checked)}
/>
</div>
<div className={styles.toggleRow}>
<Toggle
label="Auto Sign-Up"
checked={form.autoSignup}
onChange={(e) => update('autoSignup', e.target.checked)}
/>
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
</div>
</section>
<section className={styles.section}>
<SectionHeader>Provider Settings</SectionHeader>
<FormField label="Issuer URI" htmlFor="issuer">
<Input
id="issuer"
type="url"
placeholder="https://idp.example.com/realms/my-realm"
value={form.issuerUri}
onChange={(e) => update('issuerUri', e.target.value)}
/>
</FormField>
<FormField label="Client ID" htmlFor="client-id">
<Input
id="client-id"
value={form.clientId}
onChange={(e) => update('clientId', e.target.value)}
/>
</FormField>
<FormField label="Client Secret" htmlFor="client-secret">
<Input
id="client-secret"
type="password"
value={form.clientSecret}
onChange={(e) => update('clientSecret', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Claim Mapping</SectionHeader>
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
<Input
id="roles-claim"
value={form.rolesClaim}
onChange={(e) => update('rolesClaim', e.target.value)}
/>
</FormField>
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
<Input
id="name-claim"
value={form.displayNameClaim}
onChange={(e) => update('displayNameClaim', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Default Roles</SectionHeader>
<div className={styles.tagList}>
{form.defaultRoles.map((role) => (
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
))}
{form.defaultRoles.length === 0 && (
<span className={styles.noRoles}>No default roles configured</span>
)}
</div>
<div className={styles.addRoleRow}>
<Input
placeholder="Add role..."
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole() } }}
className={styles.roleInput}
/>
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
Add
</Button>
</div>
</section>
<section className={styles.section}>
<SectionHeader>Danger Zone</SectionHeader>
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
Delete OIDC Configuration
</Button>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
confirmText="delete oidc"
/>
</section>
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,312 @@
import { useState, useMemo } from 'react'
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
import { Badge } from '../../../design-system/primitives/Badge/Badge'
import { Button } from '../../../design-system/primitives/Button/Button'
import { Input } from '../../../design-system/primitives/Input/Input'
import { Select } from '../../../design-system/primitives/Select/Select'
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
import { Tag } from '../../../design-system/primitives/Tag/Tag'
import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit'
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
import { useToast } from '../../../design-system/composites/Toast/Toast'
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, getChildGroups, type MockGroup } from './rbacMocks'
import styles from './UserManagement.module.css'
export function GroupsTab() {
const { toast } = useToast()
const [groups, setGroups] = useState(MOCK_GROUPS)
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<MockGroup | null>(null)
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null)
const [newName, setNewName] = useState('')
const [newParent, setNewParent] = useState('')
const filtered = useMemo(() => {
if (!search) return groups
const q = search.toLowerCase()
return groups.filter((g) => g.name.toLowerCase().includes(q))
}, [groups, search])
const selected = groups.find((g) => g.id === selectedId) ?? null
function handleCreate() {
if (!newName.trim()) return
const newGroup: MockGroup = {
id: `grp-${Date.now()}`,
name: newName.trim(),
parentId: newParent || null,
builtIn: false,
directRoles: [],
memberUserIds: [],
}
setGroups((prev) => [...prev, newGroup])
setCreating(false)
setNewName(''); setNewParent('')
setSelectedId(newGroup.id)
toast({ title: 'Group created', description: newGroup.name, variant: 'success' })
}
function handleDelete() {
if (!deleteTarget) return
setGroups((prev) => prev.filter((g) => g.id !== deleteTarget.id))
if (selectedId === deleteTarget.id) setSelectedId(null)
setDeleteTarget(null)
toast({ title: 'Group deleted', description: deleteTarget.name, variant: 'warning' })
}
function updateGroup(id: string, patch: Partial<MockGroup>) {
setGroups((prev) => prev.map((g) => g.id === id ? { ...g, ...patch } : g))
}
const duplicateGroupName = newName.trim() !== '' && groups.some((g) => g.name.toLowerCase() === newName.trim().toLowerCase())
const children = selected ? groups.filter((g) => g.parentId === selected.id) : []
const members = selected ? MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)) : []
const parent = selected?.parentId ? groups.find((g) => g.id === selected.parentId) : null
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
.map((r) => ({ value: r.name, label: r.name }))
const availableMembers = MOCK_USERS.filter((u) => !selected || !u.directGroups.includes(selected.id))
.map((u) => ({ value: u.id, label: u.displayName }))
const availableChildGroups = groups.filter((g) => selected && g.id !== selected.id && g.parentId !== selected.id && !children.some((c) => c.id === g.id))
.map((g) => ({ value: g.id, label: g.name }))
const parentOptions = [
{ value: '', label: 'Top-level' },
...groups.filter((g) => g.id !== selectedId).map((g) => ({ value: g.id, label: g.name })),
]
return (
<>
<div className={styles.splitPane}>
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search groups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
className={styles.listHeaderSearch}
/>
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
+ Add group
</Button>
</div>
{creating && (
<div className={styles.createForm}>
<Input placeholder="Group name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
{duplicateGroupName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Group name already exists</span>}
<Select
options={parentOptions}
value={newParent}
onChange={(e) => setNewParent(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateGroupName}>Create</Button>
</div>
</div>
)}
<div className={styles.entityList} role="listbox" aria-label="Groups">
{filtered.map((group) => {
const groupChildren = groups.filter((g) => g.parentId === group.id)
const groupMembers = MOCK_USERS.filter((u) => u.directGroups.includes(group.id))
const groupParent = group.parentId ? groups.find((g) => g.id === group.parentId) : null
return (
<div
key={group.id}
className={`${styles.entityItem} ${selectedId === group.id ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(group.id)}
role="option"
tabIndex={0}
aria-selected={selectedId === group.id}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(group.id) } }}
>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{groupParent ? `Child of ${groupParent.name}` : 'Top-level'}
{' · '}{groupChildren.length} children · {groupMembers.length} members
</div>
<div className={styles.entityTags}>
{group.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
</div>
</div>
</div>
)
})}
{filtered.length === 0 && (
<div className={styles.emptySearch}>No groups match your search</div>
)}
</div>
</div>
<div className={styles.detailPane}>
{selected ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
{selected.builtIn ? selected.name : (
<InlineEdit
value={selected.name}
onSave={(v) => updateGroup(selected.id, { name: v })}
/>
)}
</div>
<div className={styles.detailEmail}>
{parent ? `${parent.name} > ${selected.name}` : 'Top-level group'}
{selected.builtIn && ' (built-in)'}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
disabled={selected.builtIn}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.id}</MonoText>
</div>
{parent && (
<>
<SectionHeader>Member of</SectionHeader>
<div className={styles.sectionTags}>
<Tag label={parent.name} color="auto" />
</div>
</>
)}
<SectionHeader>Members (direct)</SectionHeader>
<div className={styles.sectionTags}>
{members.map((u) => (
<Tag
key={u.id}
label={u.displayName}
color="auto"
onRemove={() => {
// Remove this group from the user's directGroups
// Note: in mock data we can't easily update MOCK_USERS, so this is visual only
toast({ title: 'Member removed', description: u.displayName, variant: 'success' })
}}
/>
))}
{members.length === 0 && <span className={styles.inheritedNote}>(no members)</span>}
<MultiSelect
options={availableMembers}
value={[]}
onChange={(ids) => {
toast({ title: `${ids.length} member(s) added`, variant: 'success' })
}}
placeholder="+ Add"
/>
</div>
{children.length > 0 && (
<span className={styles.inheritedNote}>
+ all members of {children.map((c) => c.name).join(', ')}
</span>
)}
<SectionHeader>Child groups</SectionHeader>
<div className={styles.sectionTags}>
{children.map((c) => (
<Tag
key={c.id}
label={c.name}
color="success"
onRemove={() => {
updateGroup(c.id, { parentId: null })
toast({ title: 'Child group removed', description: c.name, variant: 'success' })
}}
/>
))}
{children.length === 0 && <span className={styles.inheritedNote}>(no child groups)</span>}
<MultiSelect
options={availableChildGroups}
value={[]}
onChange={(ids) => {
for (const id of ids) {
updateGroup(id, { parentId: selected!.id })
}
toast({ title: `${ids.length} child group(s) added`, variant: 'success' })
}}
placeholder="+ Add"
/>
</div>
<SectionHeader>Assigned roles</SectionHeader>
<div className={styles.sectionTags}>
{selected.directRoles.map((r) => (
<Tag
key={r}
label={r}
color="warning"
onRemove={() => {
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
if (memberCount > 0) {
setRemoveRoleTarget(r)
} else {
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
toast({ title: 'Role removed', variant: 'success' })
}
}}
/>
))}
{selected.directRoles.length === 0 && <span className={styles.inheritedNote}>(no roles)</span>}
<MultiSelect
options={availableRoles}
value={[]}
onChange={(roles) => {
updateGroup(selected.id, { directRoles: [...selected.directRoles, ...roles] })
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
}}
placeholder="+ Add"
/>
</div>
</>
) : (
<div className={styles.emptyDetail}>Select a group to view details</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
/>
<AlertDialog
open={removeRoleTarget !== null}
onClose={() => setRemoveRoleTarget(null)}
onConfirm={() => {
if (removeRoleTarget && selected) {
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== removeRoleTarget) })
toast({ title: 'Role removed', variant: 'success' })
}
setRemoveRoleTarget(null)
}}
title="Remove role from group"
description={`Removing ${removeRoleTarget} from ${selected?.name} will affect ${members.length} member(s) who inherit this role. Continue?`}
confirmLabel="Remove"
variant="warning"
/>
</>
)
}

View File

@@ -0,0 +1,227 @@
import { useState, useMemo } from 'react'
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
import { Badge } from '../../../design-system/primitives/Badge/Badge'
import { Button } from '../../../design-system/primitives/Button/Button'
import { Input } from '../../../design-system/primitives/Input/Input'
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
import { Tag } from '../../../design-system/primitives/Tag/Tag'
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
import { useToast } from '../../../design-system/composites/Toast/Toast'
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
import styles from './UserManagement.module.css'
export function RolesTab() {
const { toast } = useToast()
const [roles, setRoles] = useState(MOCK_ROLES)
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<MockRole | null>(null)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const filtered = useMemo(() => {
if (!search) return roles
const q = search.toLowerCase()
return roles.filter((r) =>
r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)
)
}, [roles, search])
const selected = roles.find((r) => r.id === selectedId) ?? null
function handleCreate() {
if (!newName.trim()) return
const newRole: MockRole = {
id: `role-${Date.now()}`,
name: newName.trim().toUpperCase(),
description: newDesc.trim(),
scope: 'custom',
system: false,
}
setRoles((prev) => [...prev, newRole])
setCreating(false)
setNewName(''); setNewDesc('')
setSelectedId(newRole.id)
toast({ title: 'Role created', description: newRole.name, variant: 'success' })
}
function handleDelete() {
if (!deleteTarget) return
setRoles((prev) => prev.filter((r) => r.id !== deleteTarget.id))
if (selectedId === deleteTarget.id) setSelectedId(null)
setDeleteTarget(null)
toast({ title: 'Role deleted', description: deleteTarget.name, variant: 'warning' })
}
const duplicateRoleName = newName.trim() !== '' && roles.some((r) => r.name === newName.trim().toUpperCase())
// Role assignments
const assignedGroups = selected
? MOCK_GROUPS.filter((g) => g.directRoles.includes(selected.name))
: []
const directUsers = selected
? MOCK_USERS.filter((u) => u.directRoles.includes(selected.name))
: []
const effectivePrincipals = selected
? MOCK_USERS.filter((u) => getEffectiveRoles(u).some((r) => r.role === selected.name))
: []
function getAssignmentCount(role: MockRole): number {
const groups = MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name)).length
const users = MOCK_USERS.filter((u) => u.directRoles.includes(role.name)).length
return groups + users
}
return (
<>
<div className={styles.splitPane}>
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search roles..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
className={styles.listHeaderSearch}
/>
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
+ Add role
</Button>
</div>
{creating && (
<div className={styles.createForm}>
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
{duplicateRoleName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Role name already exists</span>}
<Input placeholder="Description" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
<div className={styles.createFormActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateRoleName}>Create</Button>
</div>
</div>
)}
<div className={styles.entityList} role="listbox" aria-label="Roles">
{filtered.map((role) => (
<div
key={role.id}
className={`${styles.entityItem} ${selectedId === role.id ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(role.id)}
role="option"
tabIndex={0}
aria-selected={selectedId === role.id}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(role.id) } }}
>
<Avatar name={role.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{role.name}
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
</div>
<div className={styles.entityMeta}>
{role.description} · {getAssignmentCount(role)} assignments
</div>
<div className={styles.entityTags}>
{MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name))
.map((g) => <Badge key={g.id} label={g.name} color="success" />)}
{MOCK_USERS.filter((u) => u.directRoles.includes(role.name))
.map((u) => <Badge key={u.id} label={u.username} color="auto" />)}
</div>
</div>
</div>
))}
{filtered.length === 0 && (
<div className={styles.emptySearch}>No roles match your search</div>
)}
</div>
</div>
<div className={styles.detailPane}>
{selected ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>{selected.name}</div>
{selected.description && (
<div className={styles.detailEmail}>{selected.description}</div>
)}
</div>
{!selected.system && (
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
>
Delete
</Button>
)}
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.id}</MonoText>
<span className={styles.metaLabel}>Scope</span>
<span className={styles.metaValue}>{selected.scope}</span>
{selected.system && (
<>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>System role (read-only)</span>
</>
)}
</div>
<SectionHeader>Assigned to groups</SectionHeader>
<div className={styles.sectionTags}>
{assignedGroups.map((g) => <Tag key={g.id} label={g.name} color="success" />)}
{assignedGroups.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
</div>
<SectionHeader>Assigned to users (direct)</SectionHeader>
<div className={styles.sectionTags}>
{directUsers.map((u) => <Tag key={u.id} label={u.displayName} color="auto" />)}
{directUsers.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
</div>
<SectionHeader>Effective principals</SectionHeader>
<div className={styles.sectionTags}>
{effectivePrincipals.map((u) => {
const isDirect = u.directRoles.includes(selected.name)
return (
<Badge
key={u.id}
label={u.displayName}
color="auto"
variant={isDirect ? 'filled' : 'dashed'}
/>
)
})}
{effectivePrincipals.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
</div>
{effectivePrincipals.some((u) => !u.directRoles.includes(selected.name)) && (
<span className={styles.inheritedNote}>
Dashed entries inherit this role through group membership
</span>
)}
</>
) : (
<div className={styles.emptyDetail}>Select a role to view details</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
/>
</>
)
}

View File

@@ -0,0 +1,229 @@
.splitPane {
display: grid;
grid-template-columns: 52fr 48fr;
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 500px;
box-shadow: var(--shadow-card);
}
.listPane {
background: var(--bg-surface);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}
.detailPane {
background: var(--bg-surface);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeaderSearch {
flex: 1;
}
.entityList {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--bg-raised);
}
.entityInfo {
flex: 1;
min-width: 0;
}
.entityName {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-body);
}
.entityMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-body);
margin-top: 2px;
}
.entityTags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.detailHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.detailHeaderInfo {
flex: 1;
min-width: 0;
}
.detailName {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-body);
}
.detailEmail {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-body);
}
.metaGrid {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin-bottom: 16px;
font-size: 12px;
font-family: var(--font-body);
}
.metaLabel {
color: var(--text-muted);
font-weight: 500;
}
.metaValue {
color: var(--text-primary);
}
.sectionTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
margin-bottom: 8px;
}
.selectWrap {
margin-top: 8px;
max-width: 240px;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-faint);
font-size: 13px;
font-family: var(--font-body);
}
.createForm {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised);
display: flex;
flex-direction: column;
gap: 8px;
}
.createFormRow {
display: flex;
gap: 8px;
}
.createFormActions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.inheritedNote {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
font-family: var(--font-body);
margin-top: 4px;
}
.providerBadge {
margin-left: 6px;
}
.inherited {
opacity: 0.65;
}
.tabContent {
margin-top: 16px;
}
.emptySearch {
padding: 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
.securitySection {
margin-top: 8px;
margin-bottom: 8px;
}
.securityRow {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
font-family: var(--font-body);
color: var(--text-primary);
}
.passwordDots {
font-family: var(--font-mono);
letter-spacing: 2px;
}
.resetForm {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.resetInput {
width: 200px;
}

View File

@@ -0,0 +1,28 @@
import { useState } from 'react'
import styles from './UserManagement.module.css'
import { AdminLayout } from '../Admin'
import { Tabs } from '../../../design-system/composites/Tabs/Tabs'
import { UsersTab } from './UsersTab'
import { GroupsTab } from './GroupsTab'
import { RolesTab } from './RolesTab'
const TABS = [
{ label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups' },
{ label: 'Roles', value: 'roles' },
]
export function UserManagement() {
const [tab, setTab] = useState('users')
return (
<AdminLayout title="User Management">
<Tabs tabs={TABS} active={tab} onChange={setTab} />
<div className={styles.tabContent}>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,379 @@
import { useState, useMemo } from 'react'
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
import { Badge } from '../../../design-system/primitives/Badge/Badge'
import { Button } from '../../../design-system/primitives/Button/Button'
import { Input } from '../../../design-system/primitives/Input/Input'
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
import { Tag } from '../../../design-system/primitives/Tag/Tag'
import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit'
import { RadioGroup, RadioItem } from '../../../design-system/primitives/Radio/Radio'
import { InfoCallout } from '../../../design-system/primitives/InfoCallout/InfoCallout'
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
import { useToast } from '../../../design-system/composites/Toast/Toast'
import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks'
import styles from './UserManagement.module.css'
export function UsersTab() {
const { toast } = useToast()
const [users, setUsers] = useState(MOCK_USERS)
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<MockUser | null>(null)
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null)
// Create form state
const [newUsername, setNewUsername] = useState('')
const [newDisplay, setNewDisplay] = useState('')
const [newEmail, setNewEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local')
const [resettingPassword, setResettingPassword] = useState(false)
const [newPw, setNewPw] = useState('')
const filtered = useMemo(() => {
if (!search) return users
const q = search.toLowerCase()
return users.filter((u) =>
u.displayName.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.username.toLowerCase().includes(q)
)
}, [users, search])
const selected = users.find((u) => u.id === selectedId) ?? null
function handleCreate() {
if (!newUsername.trim()) return
if (newProvider === 'local' && !newPassword.trim()) return
const newUser: MockUser = {
id: `usr-${Date.now()}`,
username: newUsername.trim(),
displayName: newDisplay.trim() || newUsername.trim(),
email: newEmail.trim(),
provider: newProvider,
createdAt: new Date().toISOString(),
directRoles: [],
directGroups: [],
}
setUsers((prev) => [...prev, newUser])
setCreating(false)
setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword(''); setNewProvider('local')
setSelectedId(newUser.id)
setResettingPassword(false)
toast({ title: 'User created', description: newUser.displayName, variant: 'success' })
}
function handleDelete() {
if (!deleteTarget) return
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
if (selectedId === deleteTarget.id) setSelectedId(null)
setDeleteTarget(null)
toast({ title: 'User deleted', description: deleteTarget.username, variant: 'warning' })
}
function updateUser(id: string, patch: Partial<MockUser>) {
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, ...patch } : u))
}
const duplicateUsername = newUsername.trim() !== '' && users.some((u) => u.username.toLowerCase() === newUsername.trim().toLowerCase())
const effectiveRoles = selected ? getEffectiveRoles(selected) : []
const availableGroups = MOCK_GROUPS.filter((g) => !selected?.directGroups.includes(g.id))
.map((g) => ({ value: g.id, label: g.name }))
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
.map((r) => ({ value: r.name, label: r.name }))
function getUserGroupPath(user: MockUser): string {
if (user.directGroups.length === 0) return 'no groups'
const group = MOCK_GROUPS.find((g) => g.id === user.directGroups[0])
if (!group) return 'no groups'
const parent = group.parentId ? MOCK_GROUPS.find((g) => g.id === group.parentId) : null
return parent ? `${parent.name} > ${group.name}` : group.name
}
return (
<>
<div className={styles.splitPane}>
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
className={styles.listHeaderSearch}
/>
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
+ Add user
</Button>
</div>
{creating && (
<div className={styles.createForm}>
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
<RadioItem value="local" label="Local" />
<RadioItem value="oidc" label="OIDC" />
</RadioGroup>
<div className={styles.createFormRow}>
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
</div>
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
{newProvider === 'local' && (
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
)}
{newProvider === 'oidc' && (
<InfoCallout variant="amber">
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
</InfoCallout>
)}
<div className={styles.createFormActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
disabled={!newUsername.trim() || (newProvider === 'local' && !newPassword.trim()) || duplicateUsername}
>
Create
</Button>
</div>
</div>
)}
<div className={styles.entityList} role="listbox" aria-label="Users">
{filtered.map((user) => (
<div
key={user.id}
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
onClick={() => { setSelectedId(user.id); setResettingPassword(false) }}
role="option"
tabIndex={0}
aria-selected={selectedId === user.id}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(user.id); setResettingPassword(false) } }}
>
<Avatar name={user.displayName} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{user.displayName}
{user.provider !== 'local' && (
<Badge label={user.provider} color="running" variant="outlined" className={styles.providerBadge} />
)}
</div>
<div className={styles.entityMeta}>
{user.email} &middot; {getUserGroupPath(user)}
</div>
<div className={styles.entityTags}>
{user.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
{user.directGroups.map((gId) => {
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
return g ? <Badge key={gId} label={g.name} color="success" /> : null
})}
</div>
</div>
</div>
))}
{filtered.length === 0 && (
<div className={styles.emptySearch}>No users match your search</div>
)}
</div>
</div>
<div className={styles.detailPane}>
{selected ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.displayName} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
<InlineEdit
value={selected.displayName}
onSave={(v) => updateUser(selected.id, { displayName: v })}
/>
</div>
<div className={styles.detailEmail}>{selected.email}</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
disabled={selected.username === 'hendrik'}
>
Delete
</Button>
</div>
<SectionHeader>Status</SectionHeader>
<div className={styles.sectionTags}>
<Tag label="Active" color="success" />
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.id}</MonoText>
<span className={styles.metaLabel}>Created</span>
<span className={styles.metaValue}>{new Date(selected.createdAt).toLocaleDateString()}</span>
<span className={styles.metaLabel}>Provider</span>
<span className={styles.metaValue}>{selected.provider}</span>
</div>
<SectionHeader>Security</SectionHeader>
<div className={styles.securitySection}>
{selected.provider === 'local' ? (
<>
<div className={styles.securityRow}>
<span className={styles.metaLabel}>Password</span>
<span className={styles.passwordDots}></span>
{!resettingPassword && (
<Button size="sm" variant="ghost" onClick={() => { setResettingPassword(true); setNewPw('') }}>
Reset password
</Button>
)}
</div>
{resettingPassword && (
<div className={styles.resetForm}>
<Input
placeholder="New password"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
className={styles.resetInput}
/>
<Button size="sm" variant="ghost" onClick={() => setResettingPassword(false)}>Cancel</Button>
<Button
size="sm"
variant="primary"
onClick={() => { setResettingPassword(false); toast({ title: 'Password updated', description: selected.username, variant: 'success' }) }}
disabled={!newPw.trim()}
>
Set
</Button>
</div>
)}
</>
) : (
<>
<div className={styles.securityRow}>
<span className={styles.metaLabel}>Authentication</span>
<span className={styles.metaValue}>OIDC ({selected.provider})</span>
</div>
<InfoCallout variant="amber">
Password managed by the identity provider.
</InfoCallout>
</>
)}
</div>
<SectionHeader>Group membership (direct only)</SectionHeader>
<div className={styles.sectionTags}>
{selected.directGroups.map((gId) => {
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
return g ? (
<Tag
key={gId}
label={g.name}
color="success"
onRemove={() => {
const group = MOCK_GROUPS.find((gr) => gr.id === gId)
if (group && group.directRoles.length > 0) {
setRemoveGroupTarget(gId)
} else {
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== gId) })
toast({ title: 'Group removed', variant: 'success' })
}
}}
/>
) : null
})}
{selected.directGroups.length === 0 && (
<span className={styles.inheritedNote}>(no groups)</span>
)}
<MultiSelect
options={availableGroups}
value={[]}
onChange={(ids) => {
updateUser(selected.id, { directGroups: [...selected.directGroups, ...ids] })
toast({ title: `${ids.length} group(s) added`, variant: 'success' })
}}
placeholder="+ Add"
/>
</div>
<SectionHeader>Effective roles (direct + inherited)</SectionHeader>
<div className={styles.sectionTags}>
{effectiveRoles.map(({ role, source }) =>
source === 'direct' ? (
<Tag
key={role}
label={role}
color="warning"
onRemove={() => {
updateUser(selected.id, { directRoles: selected.directRoles.filter((r) => r !== role) })
toast({ title: 'Role removed', description: role, variant: 'success' })
}}
/>
) : (
<Badge
key={role}
label={`${role}${source}`}
color="warning"
variant="dashed"
className={styles.inherited}
/>
)
)}
{effectiveRoles.length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={(roles) => {
updateUser(selected.id, { directRoles: [...selected.directRoles, ...roles] })
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
}}
placeholder="+ Add"
/>
</div>
{effectiveRoles.some((r) => r.source !== 'direct') && (
<span className={styles.inheritedNote}>
Roles with are inherited through group membership
</span>
)}
</>
) : (
<div className={styles.emptyDetail}>Select a user to view details</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete user "${deleteTarget?.username}"? This cannot be undone.`}
confirmText={deleteTarget?.username ?? ''}
/>
<AlertDialog
open={removeGroupTarget !== null}
onClose={() => setRemoveGroupTarget(null)}
onConfirm={() => {
if (removeGroupTarget && selected) {
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== removeGroupTarget) })
toast({ title: 'Group removed', variant: 'success' })
}
setRemoveGroupTarget(null)
}}
title="Remove group membership"
description={`Removing this group will also revoke inherited roles: ${MOCK_GROUPS.find((g) => g.id === removeGroupTarget)?.directRoles.join(', ') ?? ''}. Continue?`}
confirmLabel="Remove"
variant="warning"
/>
</>
)
}

View File

@@ -0,0 +1,134 @@
export interface MockUser {
id: string
username: string
displayName: string
email: string
provider: 'local' | 'oidc'
createdAt: string
directRoles: string[]
directGroups: string[]
}
export interface MockGroup {
id: string
name: string
parentId: string | null
builtIn: boolean
directRoles: string[]
memberUserIds: string[]
}
export interface MockRole {
id: string
name: string
description: string
scope: 'system' | 'custom'
system: boolean
}
export const MOCK_ROLES: MockRole[] = [
{ id: 'role-1', name: 'ADMIN', description: 'Full system access', scope: 'system', system: true },
{ id: 'role-2', name: 'USER', description: 'Standard user access', scope: 'system', system: true },
{ id: 'role-3', name: 'EDITOR', description: 'Can modify routes and configurations', scope: 'custom', system: false },
{ id: 'role-4', name: 'VIEWER', description: 'Read-only access to all resources', scope: 'custom', system: false },
{ id: 'role-5', name: 'OPERATOR', description: 'Pipeline operator — start, stop, monitor', scope: 'custom', system: false },
{ id: 'role-6', name: 'AUDITOR', description: 'Access to audit logs and compliance data', scope: 'custom', system: false },
]
export const MOCK_GROUPS: MockGroup[] = [
{ id: 'grp-1', name: 'ADMINS', parentId: null, builtIn: true, directRoles: ['ADMIN'], memberUserIds: ['usr-1'] },
{ id: 'grp-2', name: 'Developers', parentId: null, builtIn: false, directRoles: ['EDITOR'], memberUserIds: ['usr-2', 'usr-3'] },
{ id: 'grp-3', name: 'Frontend', parentId: 'grp-2', builtIn: false, directRoles: ['VIEWER'], memberUserIds: ['usr-4'] },
{ id: 'grp-4', name: 'Operations', parentId: null, builtIn: false, directRoles: ['OPERATOR', 'VIEWER'], memberUserIds: ['usr-5', 'usr-6'] },
]
export const MOCK_USERS: MockUser[] = [
{
id: 'usr-1', username: 'hendrik', displayName: 'Hendrik Siegeln',
email: 'hendrik@example.com', provider: 'local', createdAt: '2025-01-15T10:00:00Z',
directRoles: ['ADMIN'], directGroups: ['grp-1'],
},
{
id: 'usr-2', username: 'alice', displayName: 'Alice Johnson',
email: 'alice@example.com', provider: 'oidc', createdAt: '2025-03-20T14:30:00Z',
directRoles: ['VIEWER'], directGroups: ['grp-2'],
},
{
id: 'usr-3', username: 'bob', displayName: 'Bob Smith',
email: 'bob@example.com', provider: 'local', createdAt: '2025-04-10T09:00:00Z',
directRoles: [], directGroups: ['grp-2'],
},
{
id: 'usr-4', username: 'carol', displayName: 'Carol Davis',
email: 'carol@example.com', provider: 'oidc', createdAt: '2025-06-01T11:15:00Z',
directRoles: [], directGroups: ['grp-3'],
},
{
id: 'usr-5', username: 'dave', displayName: 'Dave Wilson',
email: 'dave@example.com', provider: 'local', createdAt: '2025-07-22T16:45:00Z',
directRoles: ['AUDITOR'], directGroups: ['grp-4'],
},
{
id: 'usr-6', username: 'eve', displayName: 'Eve Martinez',
email: 'eve@example.com', provider: 'oidc', createdAt: '2025-09-05T08:20:00Z',
directRoles: [], directGroups: ['grp-4'],
},
{
id: 'usr-7', username: 'frank', displayName: 'Frank Brown',
email: 'frank@example.com', provider: 'local', createdAt: '2025-11-12T13:00:00Z',
directRoles: ['USER'], directGroups: [],
},
{
id: 'usr-8', username: 'grace', displayName: 'Grace Lee',
email: 'grace@example.com', provider: 'oidc', createdAt: '2026-01-08T10:30:00Z',
directRoles: ['VIEWER', 'AUDITOR'], directGroups: [],
},
]
/** Resolve all roles for a user, including those inherited from groups */
export function getEffectiveRoles(user: MockUser): Array<{ role: string; source: 'direct' | string }> {
const result: Array<{ role: string; source: 'direct' | string }> = []
const seen = new Set<string>()
// Direct roles
for (const role of user.directRoles) {
result.push({ role, source: 'direct' })
seen.add(role)
}
// Walk group chain for inherited roles
function walkGroup(groupId: string) {
const group = MOCK_GROUPS.find((g) => g.id === groupId)
if (!group) return
for (const role of group.directRoles) {
if (!seen.has(role)) {
result.push({ role, source: group.name })
seen.add(role)
}
}
// Walk parent group
if (group.parentId) walkGroup(group.parentId)
}
for (const groupId of user.directGroups) {
walkGroup(groupId)
}
return result
}
/** Get all groups in the chain (self + ancestors) for display */
export function getGroupChain(groupId: string): MockGroup[] {
const chain: MockGroup[] = []
let current = MOCK_GROUPS.find((g) => g.id === groupId)
while (current) {
chain.unshift(current)
current = current.parentId ? MOCK_GROUPS.find((g) => g.id === current!.parentId) : undefined
}
return chain
}
/** Get child groups of a given group */
export function getChildGroups(groupId: string): MockGroup[] {
return MOCK_GROUPS.filter((g) => g.parentId === groupId)
}

View File

@@ -10,11 +10,27 @@
/* Stat strip */ /* Stat strip */
.statStrip { .statStrip {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 10px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
} }
/* Stat breakdown with colored dots */
.breakdown {
display: flex;
gap: 8px;
font-size: 11px;
font-family: var(--font-mono);
}
.bpLive { color: var(--success); display: inline-flex; align-items: center; gap: 3px; }
.bpStale { color: var(--warning); display: inline-flex; align-items: center; gap: 3px; }
.bpDead { color: var(--error); display: inline-flex; align-items: center; gap: 3px; }
.routesSuccess { color: var(--success); }
.routesWarning { color: var(--warning); }
.routesError { color: var(--error); }
/* Scope breadcrumb trail */ /* Scope breadcrumb trail */
.scopeTrail { .scopeTrail {
display: flex; display: flex;
@@ -178,11 +194,6 @@
box-shadow: inset 3px 0 0 var(--amber); box-shadow: inset 3px 0 0 var(--amber);
} }
/* Chart expansion row */
.chartRow td {
padding: 0;
}
/* Instance fields */ /* Instance fields */
.instanceName { .instanceName {
font-weight: 600; font-weight: 600;
@@ -211,17 +222,35 @@
white-space: nowrap; white-space: nowrap;
} }
/* Instance expanded charts */ /* Detail panel content */
.instanceCharts { .detailContent {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: column;
gap: 12px; gap: 12px;
padding: 12px 16px; }
background: var(--bg-raised);
border-top: 1px solid var(--border-subtle); .detailRow {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-family: var(--font-body);
padding: 4px 0;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
.detailLabel {
color: var(--text-muted);
font-weight: 500;
}
.detailProgress {
display: flex;
align-items: center;
gap: 8px;
width: 140px;
}
.chartPanel { .chartPanel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useState, useMemo } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import styles from './AgentHealth.module.css' import styles from './AgentHealth.module.css'
// Layout // Layout
@@ -11,12 +11,14 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard' import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
import { LineChart } from '../../design-system/composites/LineChart/LineChart' import { LineChart } from '../../design-system/composites/LineChart/LineChart'
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed' import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
// Primitives // Primitives
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot' import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText' import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge' import { Badge } from '../../design-system/primitives/Badge/Badge'
import { StatCard } from '../../design-system/primitives/StatCard/StatCard' import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
import { ProgressBar } from '../../design-system/primitives/ProgressBar/ProgressBar'
// Global filters // Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider' import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
@@ -31,13 +33,11 @@ import { agentEvents } from '../../mocks/agentEvents'
type Scope = type Scope =
| { level: 'all' } | { level: 'all' }
| { level: 'app'; appId: string } | { level: 'app'; appId: string }
| { level: 'instance'; appId: string; instanceId: string }
function useScope(): Scope { function useScope(): Scope {
const { '*': rest } = useParams() const { '*': rest } = useParams()
const segments = rest?.split('/').filter(Boolean) ?? [] const segments = rest?.split('/').filter(Boolean) ?? []
if (segments.length >= 2) return { level: 'instance', appId: segments[0], instanceId: segments[1] } if (segments.length >= 1) return { level: 'app', appId: segments[0] }
if (segments.length === 1) return { level: 'app', appId: segments[0] }
return { level: 'all' } return { level: 'all' }
} }
@@ -106,11 +106,8 @@ function buildBreadcrumb(scope: Scope) {
{ label: 'Applications', href: '/apps' }, { label: 'Applications', href: '/apps' },
{ label: 'Agents', href: '/agents' }, { label: 'Agents', href: '/agents' },
] ]
if (scope.level === 'app' || scope.level === 'instance') { if (scope.level === 'app') {
crumbs.push({ label: scope.appId, href: `/agents/${scope.appId}` }) crumbs.push({ label: scope.appId })
}
if (scope.level === 'instance') {
crumbs.push({ label: scope.instanceId })
} }
return crumbs return crumbs
} }
@@ -119,14 +116,14 @@ function buildBreadcrumb(scope: Scope) {
export function AgentHealth() { export function AgentHealth() {
const scope = useScope() const scope = useScope()
const navigate = useNavigate()
const { isInTimeRange } = useGlobalFilters() const { isInTimeRange } = useGlobalFilters()
const [selectedInstance, setSelectedInstance] = useState<AgentHealthData | null>(null)
const [panelOpen, setPanelOpen] = useState(false)
// Filter agents by scope // Filter agents by scope
const filteredAgents = useMemo(() => { const filteredAgents = useMemo(() => {
if (scope.level === 'all') return agents if (scope.level === 'all') return agents
if (scope.level === 'app') return agents.filter((a) => a.appId === scope.appId) return agents.filter((a) => a.appId === scope.appId)
return agents.filter((a) => a.appId === scope.appId && a.id === scope.instanceId)
}, [scope]) }, [scope])
const groups = useMemo(() => groupByApp(filteredAgents), [filteredAgents]) const groups = useMemo(() => groupByApp(filteredAgents), [filteredAgents])
@@ -138,18 +135,132 @@ export function AgentHealth() {
const deadCount = filteredAgents.filter((a) => a.status === 'dead').length const deadCount = filteredAgents.filter((a) => a.status === 'dead').length
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0) const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0) const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
const totalRoutes = filteredAgents.reduce((s, a) => s + a.totalRoutes, 0)
// Filter events by global time range // Filter events by global time range
const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp)) const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp))
// Single instance for expanded charts // Build trend data for selected instance
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null const trendData = selectedInstance ? buildTrendData(selectedInstance) : null
const trendData = singleInstance ? buildTrendData(singleInstance) : null
function handleInstanceClick(inst: AgentHealthData) {
setSelectedInstance(inst)
setPanelOpen(true)
}
// Detail panel tabs
const detailTabs = selectedInstance
? [
{
label: 'Overview',
value: 'overview',
content: (
<div className={styles.detailContent}>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Status</span>
<Badge
label={selectedInstance.status.toUpperCase()}
color={selectedInstance.status === 'live' ? 'success' : selectedInstance.status === 'stale' ? 'warning' : 'error'}
/>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Application</span>
<MonoText size="xs">{selectedInstance.appId}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Version</span>
<MonoText size="xs">{selectedInstance.version}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Uptime</span>
<MonoText size="xs">{selectedInstance.uptime}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Last Seen</span>
<MonoText size="xs">{selectedInstance.lastSeen}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Throughput</span>
<MonoText size="xs">{selectedInstance.tps.toFixed(1)}/s</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Errors</span>
<MonoText size="xs" className={selectedInstance.errorRate ? styles.instanceError : undefined}>
{selectedInstance.errorRate ?? '0 err/h'}
</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Routes</span>
<span>{selectedInstance.activeRoutes}/{selectedInstance.totalRoutes} active</span>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Memory</span>
<div className={styles.detailProgress}>
<ProgressBar
value={selectedInstance.memoryUsagePct}
variant={selectedInstance.memoryUsagePct > 85 ? 'error' : selectedInstance.memoryUsagePct > 70 ? 'warning' : 'success'}
/>
<MonoText size="xs">{selectedInstance.memoryUsagePct}%</MonoText>
</div>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>CPU</span>
<div className={styles.detailProgress}>
<ProgressBar
value={selectedInstance.cpuUsagePct}
variant={selectedInstance.cpuUsagePct > 85 ? 'error' : selectedInstance.cpuUsagePct > 70 ? 'warning' : 'success'}
/>
<MonoText size="xs">{selectedInstance.cpuUsagePct}%</MonoText>
</div>
</div>
</div>
),
},
{
label: 'Performance',
value: 'performance',
content: trendData ? (
<div className={styles.detailContent}>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<LineChart
series={[{ label: 'tps', data: trendData.throughput }]}
height={160}
width={360}
yLabel="msg/s"
/>
</div>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Error Rate (err/h)</div>
<LineChart
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
height={160}
width={360}
yLabel="err/h"
/>
</div>
</div>
) : null,
},
]
: []
const isFullWidth = scope.level !== 'all' const isFullWidth = scope.level !== 'all'
return ( return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> <AppShell
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
detail={
selectedInstance ? (
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={selectedInstance.name}
tabs={detailTabs}
/>
) : undefined
}
>
<TopBar <TopBar
breadcrumb={buildBreadcrumb(scope)} breadcrumb={buildBreadcrumb(scope)}
environment="PRODUCTION" environment="PRODUCTION"
@@ -159,36 +270,61 @@ export function AgentHealth() {
<div className={styles.content}> <div className={styles.content}>
{/* Stat strip */} {/* Stat strip */}
<div className={styles.statStrip}> <div className={styles.statStrip}>
<StatCard label="Total Instances" value={String(totalInstances)} /> <StatCard
<StatCard label="Live" value={String(liveCount)} accent="success" /> label="Total Agents"
<StatCard label="Stale" value={String(staleCount)} accent={staleCount > 0 ? 'warning' : undefined} /> value={String(totalInstances)}
<StatCard label="Dead" value={String(deadCount)} accent={deadCount > 0 ? 'error' : undefined} /> accent={deadCount > 0 ? 'warning' : 'amber'}
<StatCard label="Total TPS" value={`${totalTps.toFixed(1)}/s`} /> detail={
<StatCard label="Active Routes" value={String(totalActiveRoutes)} /> <span className={styles.breakdown}>
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
</span>
}
/>
<StatCard
label="Applications"
value={String(groups.length)}
accent="running"
detail={
<span className={styles.breakdown}>
<span className={styles.bpLive}><StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy</span>
<span className={styles.bpStale}><StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded</span>
<span className={styles.bpDead}><StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical</span>
</span>
}
/>
<StatCard
label="Active Routes"
value={<span className={styles[totalActiveRoutes === 0 ? 'routesError' : totalActiveRoutes < totalRoutes ? 'routesWarning' : 'routesSuccess']}>{totalActiveRoutes}/{totalRoutes}</span>}
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
/>
<StatCard
label="Total TPS"
value={totalTps.toFixed(1)}
accent="amber"
detail="msg/s"
trend="up"
trendValue="4.2%"
/>
<StatCard
label="Dead"
value={String(deadCount)}
accent={deadCount > 0 ? 'error' : 'success'}
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
/>
</div> </div>
{/* Scope breadcrumb trail */} {/* Scope trail + badges */}
{scope.level !== 'all' && ( <div className={styles.scopeTrail}>
<div className={styles.scopeTrail}> {scope.level !== 'all' && (
<Link to="/agents" className={styles.scopeLink}>All Agents</Link> <>
{scope.level === 'instance' && ( <Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<> <span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeCurrent}>{scope.appId}</span>
<Link to={`/agents/${scope.appId}`} className={styles.scopeLink}>{scope.appId}</Link> </>
</> )}
)}
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeCurrent}>
{scope.level === 'app' ? scope.appId : scope.instanceId}
</span>
</div>
)}
{/* Section header */}
<div className={styles.sectionHeaderRow}>
<span className={styles.sectionTitle}>
{scope.level === 'all' ? 'Agents' : scope.level === 'app' ? scope.appId : scope.instanceId}
</span>
<Badge <Badge
label={`${liveCount}/${totalInstances} live`} label={`${liveCount}/${totalInstances} live`}
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'} color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
@@ -240,78 +376,48 @@ export function AgentHealth() {
</thead> </thead>
<tbody> <tbody>
{group.instances.map((inst) => ( {group.instances.map((inst) => (
<> <tr
<tr key={inst.id}
key={inst.id} className={[
className={[ styles.instanceRow,
styles.instanceRow, selectedInstance?.id === inst.id && panelOpen ? styles.instanceRowActive : '',
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '', ].filter(Boolean).join(' ')}
].filter(Boolean).join(' ')} onClick={() => handleInstanceClick(inst)}
onClick={() => navigate(`/agents/${inst.appId}/${inst.id}`)} >
> <td className={styles.tdStatus}>
<td className={styles.tdStatus}> <StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} /> </td>
</td> <td>
<td> <MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText> </td>
</td> <td>
<td> <Badge
<Badge label={inst.status.toUpperCase()}
label={inst.status.toUpperCase()} color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'} variant="filled"
variant="filled" />
/> </td>
</td> <td>
<td> <MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText> </td>
</td> <td>
<td> <MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText> </td>
</td> <td>
<td> <MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}> {inst.errorRate ?? '0 err/h'}
{inst.errorRate ?? '0 err/h'} </MonoText>
</MonoText> </td>
</td> <td>
<td> <MonoText size="xs" className={
<MonoText size="xs" className={ inst.status === 'dead' ? styles.instanceHeartbeatDead :
inst.status === 'dead' ? styles.instanceHeartbeatDead : inst.status === 'stale' ? styles.instanceHeartbeatStale :
inst.status === 'stale' ? styles.instanceHeartbeatStale : styles.instanceMeta
styles.instanceMeta }>
}> {inst.lastSeen}
{inst.lastSeen} </MonoText>
</MonoText> </td>
</td> </tr>
</tr>
{/* Expanded charts for single instance */}
{singleInstance?.id === inst.id && trendData && (
<tr key={`${inst.id}-charts`} className={styles.chartRow}>
<td colSpan={7}>
<div className={styles.instanceCharts}>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<LineChart
series={[{ label: 'tps', data: trendData.throughput }]}
height={160}
width={480}
yLabel="msg/s"
/>
</div>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Error Rate (err/h)</div>
<LineChart
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
height={160}
width={480}
yLabel="err/h"
/>
</div>
</div>
</td>
</tr>
)}
</>
))} ))}
</tbody> </tbody>
</table> </table>

View File

@@ -0,0 +1,229 @@
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
.notFound {
padding: 60px;
text-align: center;
color: var(--text-faint);
font-size: 14px;
}
/* Stat strip — 5 columns matching /agents */
.statStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 16px;
}
/* Scope trail — matches /agents */
.scopeTrail {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
font-size: 12px;
}
.scopeLink {
color: var(--amber);
text-decoration: none;
font-weight: 500;
}
.scopeLink:hover {
text-decoration: underline;
}
.scopeSep {
color: var(--text-muted);
font-size: 10px;
}
.scopeCurrent {
color: var(--text-primary);
font-weight: 600;
font-family: var(--font-mono);
}
/* Section header — matches /agents */
.sectionHeaderRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.sectionTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
/* Charts 3x2 grid */
.chartsGrid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
margin-bottom: 20px;
}
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.chartHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.chartTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.chartMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Process info card */
.processCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 20px;
}
.processGrid {
display: grid;
grid-template-columns: auto 1fr auto 1fr;
gap: 6px 16px;
font-size: 12px;
font-family: var(--font-body);
margin-top: 12px;
}
.processLabel {
color: var(--text-muted);
font-weight: 500;
}
.fdRow {
display: flex;
align-items: center;
gap: 8px;
}
/* Log + Timeline side by side */
.bottomRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
/* Log viewer */
.logCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.logHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.logEntries {
max-height: 360px;
overflow-y: auto;
font-size: 11px;
}
.logEntry {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 5px 16px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-mono);
transition: background 0.1s;
}
.logEntry:hover {
background: var(--bg-hover);
}
.logEntry:last-child {
border-bottom: none;
}
.logTime {
flex-shrink: 0;
color: var(--text-muted);
min-width: 60px;
}
.logLogger {
flex-shrink: 0;
color: var(--text-faint);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logMsg {
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
word-break: break-word;
}
.logEmpty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
/* Timeline card */
.timelineCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 420px;
}
.timelineHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}

View File

@@ -0,0 +1,326 @@
import { useMemo } from 'react'
import { useParams, Link } from 'react-router-dom'
import styles from './AgentInstance.module.css'
// Layout
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart'
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
// Primitives
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge'
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
import { ProgressBar } from '../../design-system/primitives/ProgressBar/ProgressBar'
import { SectionHeader } from '../../design-system/primitives/SectionHeader/SectionHeader'
import { Card } from '../../design-system/primitives/Card/Card'
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Data
import { agents } from '../../mocks/agents'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
import { agentEvents } from '../../mocks/agentEvents'
import { useState } from 'react'
// ── Mock trend data ──────────────────────────────────────────────────────────
function buildTimeSeries(baseValue: number, variance: number, points = 30) {
const now = Date.now()
const interval = (6 * 60 * 60 * 1000) / points
return Array.from({ length: points }, (_, i) => ({
x: new Date(now - (points - i) * interval),
y: Math.max(0, baseValue + (Math.random() - 0.5) * variance * 2),
}))
}
function buildMemoryHistory(currentPct: number) {
return [
{ label: 'Heap Used', data: buildTimeSeries(currentPct * 0.7, 10) },
{ label: 'Heap Total', data: buildTimeSeries(currentPct * 0.9, 5) },
]
}
// ── Mock log entries ─────────────────────────────────────────────────────────
function buildLogEntries(agentName: string) {
const now = Date.now()
const MIN = 60_000
return [
{ ts: new Date(now - 1 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Route order-validation started and consuming from: direct:validate` },
{ ts: new Date(now - 2 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Total 3 routes, of which 3 are started` },
{ ts: new Date(now - 5 * MIN).toISOString(), level: 'WARN', logger: 'o.a.c.processor.errorhandler', msg: `Failed delivery for exchangeId: ID-${agentName}-1710847200000-0-1. Exhausted after 3 attempts.` },
{ ts: new Date(now - 8 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.health.HealthCheckHelper', msg: `Health check [routes] is UP` },
{ ts: new Date(now - 12 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.health.HealthCheckHelper', msg: `Health check [consumers] is UP` },
{ ts: new Date(now - 15 * MIN).toISOString(), level: 'DEBUG', logger: 'o.a.c.component.kafka', msg: `KafkaConsumer[order-events] poll returned 42 records in 18ms` },
{ ts: new Date(now - 18 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.engine.InternalRouteStartup', msg: `Route order-enrichment started and consuming from: kafka:order-events` },
{ ts: new Date(now - 25 * MIN).toISOString(), level: 'WARN', logger: 'o.a.c.component.http', msg: `HTTP endpoint https://payment-api.internal/verify returned 503 — will retry` },
{ ts: new Date(now - 30 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Apache Camel ${agentName} (CamelContext) is starting` },
{ ts: new Date(now - 32 * MIN).toISOString(), level: 'INFO', logger: 'org.springframework.boot', msg: `Started ${agentName} in 4.231 seconds (process running for 4.892)` },
]
}
function formatLogTime(iso: string): string {
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
}
// ── Mock JVM / process info ──────────────────────────────────────────────────
function buildProcessInfo(agent: typeof agents[0]) {
return {
jvmVersion: 'OpenJDK 21.0.2+13',
camelVersion: '4.4.0',
springBootVersion: '3.2.4',
pid: Math.floor(1000 + Math.random() * 90000),
startTime: new Date(Date.now() - parseDuration(agent.uptime)).toISOString(),
heapMax: '512 MB',
heapUsed: `${Math.round(512 * agent.memoryUsagePct / 100)} MB`,
nonHeapUsed: `${Math.round(80 + Math.random() * 40)} MB`,
threadCount: Math.floor(20 + Math.random() * 30),
peakThreads: Math.floor(45 + Math.random() * 20),
gcCollections: Math.floor(Math.random() * 500),
gcPauseTotal: `${(Math.random() * 2).toFixed(2)}s`,
classesLoaded: Math.floor(8000 + Math.random() * 4000),
openFileDescriptors: Math.floor(50 + Math.random() * 200),
maxFileDescriptors: 65536,
}
}
function parseDuration(s: string): number {
let ms = 0
const dMatch = s.match(/(\d+)d/)
const hMatch = s.match(/(\d+)h/)
const mMatch = s.match(/(\d+)m/)
if (dMatch) ms += parseInt(dMatch[1]) * 86400000
if (hMatch) ms += parseInt(hMatch[1]) * 3600000
if (mMatch) ms += parseInt(mMatch[1]) * 60000
return ms || 60000
}
// ── Component ────────────────────────────────────────────────────────────────
const LOG_TABS = [
{ label: 'All', value: 'all' },
{ label: 'Warnings', value: 'warn' },
{ label: 'Errors', value: 'error' },
]
export function AgentInstance() {
const { appId, instanceId } = useParams<{ appId: string; instanceId: string }>()
const { isInTimeRange } = useGlobalFilters()
const [logFilter, setLogFilter] = useState('all')
const agent = agents.find((a) => a.appId === appId && a.id === instanceId)
const instanceEvents = useMemo(() => {
if (!agent) return []
return agentEvents
.filter((e) => e.searchText?.toLowerCase().includes(agent.name.toLowerCase()))
.filter((e) => isInTimeRange(e.timestamp))
}, [agent, isInTimeRange])
if (!agent) {
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} />
<div className={styles.content}>
<div className={styles.notFound}>Agent instance not found.</div>
</div>
</AppShell>
)
}
const processInfo = buildProcessInfo(agent)
const logEntries = buildLogEntries(agent.name)
const filteredLogs = logFilter === 'all'
? logEntries
: logEntries.filter((l) => l.level === logFilter.toUpperCase())
const cpuData = buildTimeSeries(agent.cpuUsagePct, 15)
const memSeries = buildMemoryHistory(agent.memoryUsagePct)
const tpsSeries = [{ label: 'Throughput', data: buildTimeSeries(agent.tps, 5) }]
const errorSeries = [{ label: 'Errors', data: buildTimeSeries(agent.errorRate ? parseFloat(agent.errorRate) : 0.2, 2), color: 'var(--error)' }]
const threadSeries = [{ label: 'Threads', data: buildTimeSeries(processInfo.threadCount, 8) }]
const gcSeries = [{ label: 'GC Pause', data: buildTimeSeries(4, 6) }]
const statusVariant = agent.status === 'live' ? 'live' : agent.status === 'stale' ? 'stale' : 'dead'
const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/apps' },
{ label: 'Agents', href: '/agents' },
{ label: appId!, href: `/agents/${appId}` },
{ label: instanceId! },
]}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
{/* Stat strip — 5 columns matching /agents */}
<div className={styles.statStrip}>
<StatCard label="CPU" value={`${agent.cpuUsagePct}%`} accent={agent.cpuUsagePct > 85 ? 'error' : agent.cpuUsagePct > 70 ? 'warning' : 'success'} />
<StatCard label="Memory" value={`${agent.memoryUsagePct}%`} accent={agent.memoryUsagePct > 85 ? 'error' : agent.memoryUsagePct > 70 ? 'warning' : 'success'} detail={`${processInfo.heapUsed} / ${processInfo.heapMax}`} />
<StatCard label="Throughput" value={`${agent.tps.toFixed(1)}/s`} accent="amber" detail="msg/s" />
<StatCard label="Errors" value={agent.errorRate ?? '0 err/h'} accent={agent.errorRate ? 'error' : 'success'} />
<StatCard label="Uptime" value={agent.uptime || '—'} accent="running" detail={`since ${new Date(processInfo.startTime).toLocaleDateString()}`} />
</div>
{/* Scope trail + badges */}
<div className={styles.scopeTrail}>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span>
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeCurrent}>{agent.name}</span>
<Badge label={agent.status.toUpperCase()} color={statusColor} />
<Badge label={agent.version} color="auto" variant="outlined" />
<Badge label={`${agent.activeRoutes}/${agent.totalRoutes} routes`} color={agent.activeRoutes < agent.totalRoutes ? 'warning' : 'success'} />
</div>
{/* Process info card — right below stat strip */}
<div className={styles.processCard}>
<SectionHeader>Process Information</SectionHeader>
<div className={styles.processGrid}>
<span className={styles.processLabel}>JVM</span>
<MonoText size="xs">{processInfo.jvmVersion}</MonoText>
<span className={styles.processLabel}>Camel</span>
<MonoText size="xs">{processInfo.camelVersion}</MonoText>
<span className={styles.processLabel}>Spring Boot</span>
<MonoText size="xs">{processInfo.springBootVersion}</MonoText>
<span className={styles.processLabel}>Started</span>
<MonoText size="xs">{new Date(processInfo.startTime).toLocaleString()}</MonoText>
<span className={styles.processLabel}>File Descriptors</span>
<MonoText size="xs">{processInfo.openFileDescriptors} / {processInfo.maxFileDescriptors.toLocaleString()}</MonoText>
</div>
</div>
{/* Charts grid — 3x2 (CPU, Memory, Throughput, Errors, Threads, GC) */}
<div className={styles.chartsGrid}>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<span className={styles.chartTitle}>CPU Usage</span>
<span className={styles.chartMeta}>{agent.cpuUsagePct}% current</span>
</div>
<AreaChart
series={[{ label: 'CPU %', data: cpuData }]}
height={160}
yLabel="%"
thresholdValue={85}
thresholdLabel="Alert"
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<span className={styles.chartTitle}>Memory (Heap)</span>
<span className={styles.chartMeta}>{processInfo.heapUsed} / {processInfo.heapMax}</span>
</div>
<AreaChart
series={memSeries}
height={160}
yLabel="MB"
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<span className={styles.chartTitle}>Throughput</span>
<span className={styles.chartMeta}>{agent.tps.toFixed(1)} msg/s</span>
</div>
<LineChart
series={tpsSeries}
height={160}
yLabel="msg/s"
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<span className={styles.chartTitle}>Error Rate</span>
<span className={styles.chartMeta}>{agent.errorRate ?? '0 err/h'}</span>
</div>
<LineChart
series={errorSeries}
height={160}
yLabel="err/h"
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<span className={styles.chartTitle}>Thread Count</span>
<span className={styles.chartMeta}>{processInfo.threadCount} active</span>
</div>
<LineChart series={threadSeries} height={160} yLabel="threads" />
</div>
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<span className={styles.chartTitle}>GC Pauses</span>
<span className={styles.chartMeta}>{processInfo.gcPauseTotal} total</span>
</div>
<LineChart series={gcSeries} height={160} yLabel="ms" />
</div>
</div>
{/* Log + Timeline side by side */}
<div className={styles.bottomRow}>
{/* Log viewer */}
<div className={styles.logCard}>
<div className={styles.logHeader}>
<SectionHeader>Application Log</SectionHeader>
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
</div>
<div className={styles.logEntries}>
{filteredLogs.map((entry, i) => (
<div key={i} className={styles.logEntry}>
<MonoText size="xs" className={styles.logTime}>{formatLogTime(entry.ts)}</MonoText>
<Badge
label={entry.level}
color={entry.level === 'WARN' ? 'warning' : entry.level === 'ERROR' ? 'error' : entry.level === 'DEBUG' ? 'auto' : 'success'}
/>
<MonoText size="xs" className={styles.logLogger}>{entry.logger}</MonoText>
<span className={styles.logMsg}>{entry.msg}</span>
</div>
))}
{filteredLogs.length === 0 && (
<div className={styles.logEmpty}>No log entries match the selected filter.</div>
)}
</div>
</div>
{/* Timeline */}
<div className={styles.timelineCard}>
<div className={styles.timelineHeader}>
<span className={styles.chartTitle}>Timeline</span>
<span className={styles.chartMeta}>{instanceEvents.length} events</span>
</div>
{instanceEvents.length > 0 ? (
<EventFeed events={instanceEvents} />
) : (
<div className={styles.logEmpty}>No events in the selected time range.</div>
)}
</div>
</div>
</div>
</AppShell>
)
}

View File

@@ -69,14 +69,9 @@
color: var(--text-primary); color: var(--text-primary);
} }
.routeGroup { /* Application column */
font-size: 10px; .appName {
color: var(--text-muted); font-size: 12px;
font-family: var(--font-mono);
}
/* Customer text */
.customerText {
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -146,12 +141,46 @@
margin-top: 3px; margin-top: 3px;
} }
/* Detail panel: overview tab */ /* Detail panel sections */
.overviewTab { .panelSection {
padding: 16px; padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
}
.panelSection:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.panelSectionTitle {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.panelSectionMeta {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: var(--text-faint);
}
/* Overview grid */
.overviewGrid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
} }
.overviewRow { .overviewRow {
@@ -166,17 +195,17 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.6px; letter-spacing: 0.6px;
color: var(--text-muted); color: var(--text-muted);
width: 100px; width: 90px;
flex-shrink: 0; flex-shrink: 0;
padding-top: 2px; padding-top: 2px;
} }
/* Error block */
.errorBlock { .errorBlock {
background: var(--error-bg); background: var(--error-bg);
border: 1px solid var(--error-border); border: 1px solid var(--error-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 10px 12px; padding: 10px 12px;
margin-top: 4px;
} }
.errorClass { .errorClass {
@@ -184,7 +213,7 @@
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--error); color: var(--error);
margin-bottom: 6px; margin-bottom: 4px;
} }
.errorMessage { .errorMessage {
@@ -192,40 +221,45 @@
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
font-family: var(--font-mono); font-family: var(--font-mono);
}
/* Detail panel: processors tab */
.processorsTab {
padding: 16px;
}
/* Detail panel: exchange tab */
.exchangeTab {
padding: 16px;
}
/* Detail panel: error tab */
.errorTab {
padding: 16px;
}
.emptyTabMsg {
font-size: 12px;
color: var(--text-muted);
text-align: center;
padding: 40px 0;
}
.errorPre {
font-family: var(--font-mono);
font-size: 11px;
color: var(--error);
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-sm);
padding: 12px;
white-space: pre-wrap;
word-break: break-word; word-break: break-word;
line-height: 1.5; }
margin-top: 8px;
/* Inspect exchange icon in table */
.inspectLink {
background: transparent;
border: none;
color: var(--text-faint);
opacity: 0.75;
cursor: pointer;
font-size: 13px;
padding: 2px 4px;
border-radius: var(--radius-sm);
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s, opacity 0.15s;
}
.inspectLink:hover {
color: var(--text-primary);
opacity: 1;
}
/* Open full details link in panel */
.openDetailLink {
background: transparent;
border: none;
color: var(--amber);
cursor: pointer;
font-size: 12px;
padding: 0;
font-family: var(--font-body);
transition: color 0.1s;
}
.openDetailLink:hover {
color: var(--amber-deep);
text-decoration: underline;
text-underline-offset: 2px;
} }

View File

@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useParams } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import styles from './Dashboard.module.css' import styles from './Dashboard.module.css'
// Layout // Layout
@@ -13,6 +13,8 @@ import type { Column } from '../../design-system/composites/DataTable/types'
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel' import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar' import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline' import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
// Primitives // Primitives
import { StatCard } from '../../design-system/primitives/StatCard/StatCard' import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
@@ -26,7 +28,10 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
// Mock data // Mock data
import { exchanges, type Exchange } from '../../mocks/exchanges' import { exchanges, type Exchange } from '../../mocks/exchanges'
import { kpiMetrics } from '../../mocks/metrics' import { kpiMetrics } from '../../mocks/metrics'
import { SIDEBAR_APPS } from '../../mocks/sidebar' import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
// Route → Application lookup
const ROUTE_TO_APP = buildRouteToAppMap()
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -36,7 +41,13 @@ function formatDuration(ms: number): string {
} }
function formatTimestamp(date: Date): string { function formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) const y = date.getFullYear()
const mo = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
} }
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' { function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
@@ -57,8 +68,8 @@ function statusLabel(status: Exchange['status']): string {
} }
} }
// ─── Table columns ──────────────────────────────────────────────────────────── // ─── Table columns (base, without navigate action) ──────────────────────────
const COLUMNS: Column<Exchange>[] = [ const BASE_COLUMNS: Column<Exchange>[] = [
{ {
key: 'status', key: 'status',
header: 'Status', header: 'Status',
@@ -75,25 +86,23 @@ const COLUMNS: Column<Exchange>[] = [
header: 'Route', header: 'Route',
sortable: true, sortable: true,
render: (_, row) => ( render: (_, row) => (
<div> <span className={styles.routeName}>{row.route}</span>
<div className={styles.routeName}>{row.route}</div>
<div className={styles.routeGroup}>{row.routeGroup}</div>
</div>
), ),
}, },
{ {
key: 'orderId', key: 'routeGroup',
header: 'Order ID', header: 'Application',
sortable: true, sortable: true,
render: (_, row) => ( render: (_, row) => (
<MonoText size="sm">{row.orderId}</MonoText> <span className={styles.appName}>{ROUTE_TO_APP.get(row.route) ?? row.routeGroup}</span>
), ),
}, },
{ {
key: 'customer', key: 'id',
header: 'Customer', header: 'Exchange ID',
sortable: true,
render: (_, row) => ( render: (_, row) => (
<MonoText size="xs" className={styles.customerText}>{row.customer}</MonoText> <MonoText size="xs">{row.id}</MonoText>
), ),
}, },
{ {
@@ -143,11 +152,35 @@ const SHORTCUTS = [
// ─── Dashboard component ────────────────────────────────────────────────────── // ─── Dashboard component ──────────────────────────────────────────────────────
export function Dashboard() { export function Dashboard() {
const { id: appId } = useParams<{ id: string }>() const { id: appId, routeId } = useParams<{ id: string; routeId: string }>()
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | undefined>() const [selectedId, setSelectedId] = useState<string | undefined>()
const [panelOpen, setPanelOpen] = useState(false) const [panelOpen, setPanelOpen] = useState(false)
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null) const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
// Build columns with inspect action as second column
const COLUMNS: Column<Exchange>[] = useMemo(() => {
const inspectCol: Column<Exchange> = {
key: 'correlationId' as keyof Exchange,
header: '',
width: '36px',
render: (_, row) => (
<button
className={styles.inspectLink}
title="Inspect exchange"
onClick={(e) => {
e.stopPropagation()
navigate(`/exchanges/${row.id}`)
}}
>
&#x2197;
</button>
),
}
const [statusCol, ...rest] = BASE_COLUMNS
return [statusCol, inspectCol, ...rest]
}, [navigate])
const { isInTimeRange, statusFilters } = useGlobalFilters() const { isInTimeRange, statusFilters } = useGlobalFilters()
// Build set of route IDs belonging to the selected app (if any) // Build set of route IDs belonging to the selected app (if any)
@@ -158,11 +191,12 @@ export function Dashboard() {
return new Set(app.routes.map((r) => r.id)) return new Set(app.routes.map((r) => r.id))
}, [appId]) }, [appId])
// Scope all data to the selected app // Scope all data to the selected app (and optionally route)
const scopedExchanges = useMemo(() => { const scopedExchanges = useMemo(() => {
if (routeId) return exchanges.filter((e) => e.route === routeId)
if (!appRouteIds) return exchanges if (!appRouteIds) return exchanges
return exchanges.filter((e) => appRouteIds.has(e.route)) return exchanges.filter((e) => appRouteIds.has(e.route))
}, [appRouteIds]) }, [appRouteIds, routeId])
// Filter exchanges (scoped + global filters) // Filter exchanges (scoped + global filters)
const filteredExchanges = useMemo(() => { const filteredExchanges = useMemo(() => {
@@ -191,98 +225,33 @@ export function Dashboard() {
return undefined return undefined
} }
// Build detail panel tabs for selected exchange // Map processor types to RouteNode types
const detailTabs = selectedExchange function toRouteNodeType(procType: string): RouteNode['type'] {
? [ switch (procType) {
{ case 'consumer': return 'from'
label: 'Overview', case 'transform': return 'process'
value: 'overview', case 'enrich': return 'process'
content: ( default: return procType as RouteNode['type']
<div className={styles.overviewTab}> }
<div className={styles.overviewRow}> }
<span className={styles.overviewLabel}>Order ID</span>
<MonoText size="sm">{selectedExchange.orderId}</MonoText> // Build RouteFlow nodes from exchange processors
</div> const routeNodes: RouteNode[] = selectedExchange
<div className={styles.overviewRow}> ? selectedExchange.processors.map((p) => ({
<span className={styles.overviewLabel}>Route</span> name: p.name,
<span>{selectedExchange.route}</span> type: toRouteNodeType(p.type),
</div> durationMs: p.durationMs,
<div className={styles.overviewRow}> status: p.status,
<span className={styles.overviewLabel}>Status</span> }))
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(selectedExchange.status)} />
<span>{statusLabel(selectedExchange.status)}</span>
</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Customer</span>
<MonoText size="sm">{selectedExchange.customer}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Agent</span>
<MonoText size="sm">{selectedExchange.agent}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation ID</span>
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
</div>
{selectedExchange.errorMessage && (
<div className={styles.errorBlock}>
<div className={styles.errorClass}>{selectedExchange.errorClass}</div>
<div className={styles.errorMessage}>{selectedExchange.errorMessage}</div>
</div>
)}
</div>
),
},
{
label: 'Processors',
value: 'processors',
content: (
<div className={styles.processorsTab}>
<ProcessorTimeline
processors={selectedExchange.processors}
totalMs={selectedExchange.durationMs}
/>
</div>
),
},
{
label: 'Exchange',
value: 'exchange',
content: (
<div className={styles.exchangeTab}>
<div className={styles.emptyTabMsg}>Exchange snapshot not available in mock mode.</div>
</div>
),
},
{
label: 'Error',
value: 'error',
content: (
<div className={styles.errorTab}>
{selectedExchange.errorMessage ? (
<>
<div className={styles.errorClass}>{selectedExchange.errorClass}</div>
<pre className={styles.errorPre}>{selectedExchange.errorMessage}</pre>
</>
) : (
<div className={styles.emptyTabMsg}>No error for this exchange.</div>
)}
</div>
),
},
]
: [] : []
// Collect errors from processors
const processorErrors = selectedExchange
? selectedExchange.processors.filter((p) => p.status === 'fail')
: []
const hasExchangeError = selectedExchange?.errorMessage != null
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
@@ -294,16 +263,104 @@ export function Dashboard() {
open={panelOpen} open={panelOpen}
onClose={() => setPanelOpen(false)} onClose={() => setPanelOpen(false)}
title={`${selectedExchange.orderId}${selectedExchange.route}`} title={`${selectedExchange.orderId}${selectedExchange.route}`}
tabs={detailTabs} >
/> {/* Link to full detail page */}
<div className={styles.panelSection}>
<button
className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
>
Open full details &#x2192;
</button>
</div>
{/* Overview */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Overview</div>
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(selectedExchange.status)} />
<span>{statusLabel(selectedExchange.status)}</span>
</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span>
<span>{selectedExchange.route}</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Customer</span>
<MonoText size="sm">{selectedExchange.customer}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Agent</span>
<MonoText size="sm">{selectedExchange.agent}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation</span>
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
</div>
</div>
</div>
{/* Errors */}
{totalErrors > 0 && (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>
Errors
{totalErrors > 1 && (
<Badge label={`+${totalErrors - 1} more`} color="error" variant="outlined" />
)}
</div>
<div className={styles.errorBlock}>
<div className={styles.errorClass}>
{selectedExchange.errorClass ?? processorErrors[0]?.name}
</div>
<div className={styles.errorMessage}>
{selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`}
</div>
</div>
</div>
)}
{/* Route Flow */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
<RouteFlow nodes={routeNodes} />
</div>
{/* Processor Timeline */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>
Processor Timeline
<span className={styles.panelSectionMeta}>{formatDuration(selectedExchange.durationMs)}</span>
</div>
<ProcessorTimeline
processors={selectedExchange.processors}
totalMs={selectedExchange.durationMs}
/>
</div>
</DetailPanel>
) : undefined ) : undefined
} }
> >
{/* Top bar */} {/* Top bar */}
<TopBar <TopBar
breadcrumb={appId breadcrumb={
? [{ label: 'Applications', href: '/apps' }, { label: appId }] routeId
: [{ label: 'Applications' }] ? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
: appId
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
: [{ label: 'Applications' }]
} }
environment="PRODUCTION" environment="PRODUCTION"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}

View File

@@ -7,7 +7,9 @@
background: var(--bg-body); background: var(--bg-body);
} }
/* Exchange header card */ /* ==========================================================================
EXCHANGE HEADER CARD
========================================================================== */
.exchangeHeader { .exchangeHeader {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
@@ -88,17 +90,85 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Section layout */ /* ==========================================================================
.section { CORRELATION CHAIN
========================================================================== */
.correlationChain {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid var(--border-subtle);
flex-wrap: wrap;
}
.chainLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-right: 4px;
}
.chainNode {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
background: var(--bg-surface);
color: var(--text-secondary);
transition: all 0.12s;
}
.chainNode:hover {
border-color: var(--text-faint);
background: var(--bg-hover);
}
.chainNodeCurrent {
background: var(--amber-bg);
border-color: var(--amber-light);
color: var(--amber-deep);
font-weight: 600;
}
.chainNodeSuccess {
border-left: 3px solid var(--success);
}
.chainNodeError {
border-left: 3px solid var(--error);
}
.chainNodeRunning {
border-left: 3px solid var(--running);
}
.chainNodeWarning {
border-left: 3px solid var(--warning);
}
/* ==========================================================================
TIMELINE SECTION
========================================================================== */
.timelineSection {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 16px; margin-bottom: 16px;
overflow: hidden;
} }
.sectionHeader { .timelineHeader {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -106,159 +176,255 @@
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
.sectionTitle { .timelineTitle {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
}
.sectionMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Timeline wrapper */
.timelineWrap {
padding: 12px 16px;
}
/* Inspector steps */
.inspectorSteps {
display: flex;
flex-direction: column;
}
.stepCollapsible {
border-bottom: 1px solid var(--border-subtle);
}
.stepCollapsible:last-child {
border-bottom: none;
}
.stepTitle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
} }
.stepIndex { .procCount {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
font-family: var(--font-mono); font-family: var(--font-mono);
flex-shrink: 0; font-size: 10px;
}
.stepOk {
background: var(--success-bg);
color: var(--success);
border: 1px solid var(--success-border);
}
.stepSlow {
background: var(--warning-bg);
color: var(--warning);
border: 1px solid var(--warning-border);
}
.stepFail {
background: var(--error-bg);
color: var(--error);
border: 1px solid var(--error-border);
}
.stepName {
font-size: 12px;
font-weight: 500; font-weight: 500;
font-family: var(--font-mono); padding: 1px 8px;
color: var(--text-primary); border-radius: 10px;
flex: 1; background: var(--bg-inset);
}
.stepDuration {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-muted); color: var(--text-muted);
margin-left: auto;
flex-shrink: 0;
} }
/* Step body (two-column layout) */ .timelineToggle {
.stepBody { display: inline-flex;
display: grid; gap: 0;
grid-template-columns: 1fr 2fr; border: 1px solid var(--border-subtle);
gap: 12px; border-radius: var(--radius-sm);
overflow: hidden;
}
.toggleBtn {
padding: 4px 12px;
font-size: 11px;
font-family: var(--font-body);
border: none;
background: transparent;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.12s;
}
.toggleBtn:hover {
background: var(--bg-hover);
}
.toggleBtnActive {
background: var(--amber);
color: #fff;
font-weight: 600;
}
.toggleBtnActive:hover {
background: var(--amber-deep);
}
.timelineBody {
padding: 12px 16px; padding: 12px 16px;
background: var(--bg-raised);
} }
.stepPanel { /* ==========================================================================
DETAIL SPLIT (IN / OUT panels)
========================================================================== */
.detailSplit {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.detailPanel {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.detailPanelError {
border-color: var(--error-border);
}
.panelHeader {
display: flex; display: flex;
flex-direction: column; align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised);
gap: 8px;
}
.detailPanelError .panelHeader {
background: var(--error-bg);
border-bottom-color: var(--error-border);
}
.panelTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
display: flex;
flex-direction: row;
align-items: center;
gap: 6px; gap: 6px;
} }
.stepPanelLabel { .arrowIn {
color: var(--success);
font-weight: 700;
}
.arrowOut {
color: var(--running);
font-weight: 700;
}
.arrowError {
color: var(--error);
font-weight: 700;
font-size: 16px;
}
.panelTag {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: var(--bg-inset);
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
}
.panelBody {
padding: 16px;
}
/* Headers section */
.headersSection {
margin-bottom: 12px;
}
.headerList {
display: flex;
flex-direction: column;
gap: 0;
}
.headerKvRow {
display: grid;
grid-template-columns: 140px 1fr;
padding: 4px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 11px;
}
.headerKvRow:last-child {
border-bottom: none;
}
.headerKey {
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.headerValue {
font-family: var(--font-mono);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Body section */
.bodySection {
margin-top: 12px;
}
.sectionLabel {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.6px; letter-spacing: 0.6px;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
} }
.codeBlock { .count {
flex: 1;
max-height: 200px;
overflow-y: auto;
}
/* Error section */
.errorSection {
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 16px;
}
.errorBody {
padding: 16px;
}
.errorClass {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 10px;
font-weight: 700; padding: 0 5px;
color: var(--error); border-radius: 8px;
background: var(--bg-inset);
color: var(--text-faint);
}
/* Error panel styles */
.errorBadgeRow {
display: flex;
gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.errorMessage { .errorHttpBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
background: var(--error-bg);
color: var(--error);
border: 1px solid var(--error-border);
}
.errorMessageBox {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--text-secondary); color: var(--text-secondary);
background: var(--bg-surface); background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-sm);
padding: 10px 12px; padding: 10px 12px;
white-space: pre-wrap; border-radius: var(--radius-sm);
word-break: break-word; border: 1px solid var(--error-border);
margin-bottom: 12px;
line-height: 1.5; line-height: 1.5;
margin-bottom: 8px; word-break: break-word;
white-space: pre-wrap;
} }
.errorHint { .errorDetailGrid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 4px 12px;
font-size: 11px; font-size: 11px;
color: var(--text-muted); }
display: flex;
align-items: center; .errorDetailLabel {
gap: 5px; font-weight: 600;
color: var(--text-muted);
font-family: var(--font-mono);
}
.errorDetailValue {
color: var(--text-primary);
font-family: var(--font-mono);
word-break: break-all;
} }

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react' import { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import styles from './ExchangeDetail.module.css' import styles from './ExchangeDetail.module.css'
@@ -10,18 +10,21 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites // Composites
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline' import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
import type { ProcessorStep } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline' import type { ProcessorStep } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
// Primitives // Primitives
import { Badge } from '../../design-system/primitives/Badge/Badge' import { Badge } from '../../design-system/primitives/Badge/Badge'
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot' import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText' import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Collapsible } from '../../design-system/primitives/Collapsible/Collapsible'
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock' import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout' import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
// Mock data // Mock data
import { exchanges } from '../../mocks/exchanges' import { exchanges } from '../../mocks/exchanges'
import { SIDEBAR_APPS } from '../../mocks/sidebar' import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
const ROUTE_TO_APP = buildRouteToAppMap()
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -48,8 +51,7 @@ function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'):
} }
} }
// ─── Exchange body mock generator ──────────────────────────────────────────── // ─── Exchange body mock generators ──────────────────────────────────────────
// For each processor step, generate a plausible exchange body snapshot
function generateExchangeSnapshot( function generateExchangeSnapshot(
step: ProcessorStep, step: ProcessorStep,
orderId: string, orderId: string,
@@ -65,7 +67,7 @@ function generateExchangeSnapshot(
} }
const headers: Record<string, string> = { const headers: Record<string, string> = {
'CamelCorrelationId': `cmr-${Math.random().toString(36).slice(2, 10)}`, 'CamelCorrelationId': `cmr-${orderId.toLowerCase().replace('op-', '')}-${stepIndex}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'CamelTimerName': step.name, 'CamelTimerName': step.name,
'CamelBreadcrumbId': `${orderId}-${stepIndex}`, 'CamelBreadcrumbId': `${orderId}-${stepIndex}`,
@@ -100,6 +102,61 @@ function generateExchangeSnapshot(
} }
} }
function generateExchangeSnapshotOut(
step: ProcessorStep,
orderId: string,
customer: string,
stepIndex: number,
) {
const statusResult = step.status === 'fail' ? 'ERROR' : step.status === 'slow' ? 'SLOW_OK' : 'OK'
const baseBody = {
orderId,
customer,
status: statusResult,
processorStep: step.name,
stepIndex,
processed: true,
}
const headers: Record<string, string> = {
'CamelCorrelationId': `cmr-${orderId.toLowerCase().replace('op-', '')}-${stepIndex}`,
'Content-Type': 'application/json',
'CamelTimerName': step.name,
'CamelBreadcrumbId': `${orderId}-${stepIndex}`,
'CamelProcessedAt': new Date().toISOString(),
}
if (step.type === 'enrich') {
const source = step.name.replace('enrich(', '').replace(')', '')
return {
headers: {
...headers,
'enrichedBy': source,
'enrichmentComplete': 'true',
},
body: JSON.stringify({
...baseBody,
enrichment: { source: step.name, addedFields: ['customerId', 'address', 'tier'], resolved: true },
}, null, 2),
}
}
return {
headers,
body: JSON.stringify(baseBody, null, 2),
}
}
// Map processor types to RouteNode types
function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'transform': return 'process'
case 'enrich': return 'process'
default: return procType as RouteNode['type']
}
}
// ─── ExchangeDetail component ───────────────────────────────────────────────── // ─── ExchangeDetail component ─────────────────────────────────────────────────
export function ExchangeDetail() { export function ExchangeDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -107,6 +164,35 @@ export function ExchangeDetail() {
const exchange = useMemo(() => exchanges.find((e) => e.id === id), [id]) const exchange = useMemo(() => exchanges.find((e) => e.id === id), [id])
// Find correlated exchanges, sorted by start time
const correlatedExchanges = useMemo(() => {
if (!exchange?.correlationGroup) return []
return exchanges
.filter((e) => e.correlationGroup === exchange.correlationGroup)
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
}, [exchange])
// Default selected processor: first failed, or 0
const defaultIndex = useMemo(() => {
if (!exchange) return 0
const failIdx = exchange.processors.findIndex((p) => p.status === 'fail')
return failIdx >= 0 ? failIdx : 0
}, [exchange])
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number>(defaultIndex)
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
// Build RouteFlow nodes from exchange processors
const routeNodes: RouteNode[] = useMemo(() => {
if (!exchange) return []
return exchange.processors.map((p) => ({
name: p.name,
type: toRouteNodeType(p.type),
durationMs: p.durationMs,
status: p.status,
}))
}, [exchange])
// Not found state // Not found state
if (!exchange) { if (!exchange) {
return ( return (
@@ -122,7 +208,6 @@ export function ExchangeDetail() {
{ label: id ?? 'Unknown' }, { label: id ?? 'Unknown' },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<div className={styles.content}> <div className={styles.content}>
@@ -134,6 +219,14 @@ export function ExchangeDetail() {
const statusVariant = statusToVariant(exchange.status) const statusVariant = statusToVariant(exchange.status)
const statusLabel = statusToLabel(exchange.status) const statusLabel = statusToLabel(exchange.status)
const selectedProc = exchange.processors[selectedProcessorIndex]
const snapshotIn = selectedProc
? generateExchangeSnapshot(selectedProc, exchange.orderId, exchange.customer, selectedProcessorIndex)
: null
const snapshotOut = selectedProc
? generateExchangeSnapshotOut(selectedProc, exchange.orderId, exchange.customer, selectedProcessorIndex)
: null
const isSelectedFailed = selectedProc?.status === 'fail'
return ( return (
<AppShell <AppShell
@@ -145,7 +238,7 @@ export function ExchangeDetail() {
<TopBar <TopBar
breadcrumb={[ breadcrumb={[
{ label: 'Applications', href: '/apps' }, { label: 'Applications', href: '/apps' },
{ label: exchange.route, href: `/routes/${exchange.route}` }, { label: exchange.route, href: `/apps/${ROUTE_TO_APP.get(exchange.route) ?? exchange.route}/${exchange.route}` },
{ label: exchange.id }, { label: exchange.id },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
@@ -155,7 +248,7 @@ export function ExchangeDetail() {
{/* Scrollable content */} {/* Scrollable content */}
<div className={styles.content}> <div className={styles.content}>
{/* Exchange header */} {/* Exchange header card */}
<div className={styles.exchangeHeader}> <div className={styles.exchangeHeader}>
<div className={styles.headerRow}> <div className={styles.headerRow}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
@@ -166,10 +259,10 @@ export function ExchangeDetail() {
<Badge label={statusLabel} color={statusVariant} variant="filled" /> <Badge label={statusLabel} color={statusVariant} variant="filled" />
</div> </div>
<div className={styles.exchangeRoute}> <div className={styles.exchangeRoute}>
Route: <span className={styles.routeLink} onClick={() => navigate(`/routes/${exchange.route}`)}>{exchange.route}</span> Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${ROUTE_TO_APP.get(exchange.route) ?? exchange.route}/${exchange.route}`)}>{exchange.route}</span>
<span className={styles.headerDivider}>·</span> <span className={styles.headerDivider}>&middot;</span>
Order: <MonoText size="xs">{exchange.orderId}</MonoText> Order: <MonoText size="xs">{exchange.orderId}</MonoText>
<span className={styles.headerDivider}>·</span> <span className={styles.headerDivider}>&middot;</span>
Customer: <MonoText size="xs">{exchange.customer}</MonoText> Customer: <MonoText size="xs">{exchange.customer}</MonoText>
</div> </div>
</div> </div>
@@ -195,98 +288,168 @@ export function ExchangeDetail() {
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Processor timeline */} {/* Correlation Chain */}
<div className={styles.section}> {correlatedExchanges.length > 1 && (
<div className={styles.sectionHeader}> <div className={styles.correlationChain}>
<span className={styles.sectionTitle}>Processor Timeline</span> <span className={styles.chainLabel}>Correlated Exchanges</span>
<span className={styles.sectionMeta}>Total: {formatDuration(exchange.durationMs)}</span> {correlatedExchanges.map((ce) => {
</div> const isCurrent = ce.id === exchange.id
<div className={styles.timelineWrap}> const variant = statusToVariant(ce.status)
<ProcessorTimeline const statusCls =
processors={exchange.processors} variant === 'success' ? styles.chainNodeSuccess
totalMs={exchange.durationMs} : variant === 'error' ? styles.chainNodeError
/> : variant === 'running' ? styles.chainNodeRunning
</div> : styles.chainNodeWarning
</div> return (
<button
{/* Step-by-step inspector */} key={ce.id}
<div className={styles.section}> className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
<div className={styles.sectionHeader}> onClick={() => {
<span className={styles.sectionTitle}>Exchange Inspector</span> if (!isCurrent) navigate(`/exchanges/${ce.id}`)
<span className={styles.sectionMeta}>{exchange.processors.length} processor steps</span> }}
</div> title={`${ce.id}${ce.route}`}
<div className={styles.inspectorSteps}> >
{exchange.processors.map((proc, index) => { <StatusDot variant={variant} />
const snapshot = generateExchangeSnapshot(proc, exchange.orderId, exchange.customer, index) <span>{ce.route}</span>
const stepStatusClass = </button>
proc.status === 'fail' )
? styles.stepFail })}
: proc.status === 'slow'
? styles.stepSlow
: styles.stepOk
return (
<Collapsible
key={index}
title={
<div className={styles.stepTitle}>
<span className={`${styles.stepIndex} ${stepStatusClass}`}>{index + 1}</span>
<span className={styles.stepName}>{proc.name}</span>
<Badge
label={proc.status.toUpperCase()}
color={proc.status === 'fail' ? 'error' : proc.status === 'slow' ? 'warning' : 'success'}
variant="outlined"
/>
<span className={styles.stepDuration}>{formatDuration(proc.durationMs)}</span>
</div>
}
defaultOpen={proc.status === 'fail'}
className={styles.stepCollapsible}
>
<div className={styles.stepBody}>
<div className={styles.stepPanel}>
<div className={styles.stepPanelLabel}>Exchange Headers</div>
<CodeBlock
content={JSON.stringify(snapshot.headers, null, 2)}
language="json"
copyable
className={styles.codeBlock}
/>
</div>
<div className={styles.stepPanel}>
<div className={styles.stepPanelLabel}>Exchange Body</div>
<CodeBlock
content={snapshot.body}
language="json"
copyable
className={styles.codeBlock}
/>
</div>
</div>
</Collapsible>
)
})}
</div>
</div>
{/* Error block (if failed) */}
{exchange.status === 'failed' && exchange.errorMessage && (
<div className={styles.errorSection}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Error Details</span>
<Badge label="FAILED" color="error" />
</div> </div>
<div className={styles.errorBody}> )}
<div className={styles.errorClass}>{exchange.errorClass}</div> </div>
<pre className={styles.errorMessage}>{exchange.errorMessage}</pre>
<div className={styles.errorHint}> {/* Processor Timeline Section */}
Failed at processor: <MonoText size="xs"> <div className={styles.timelineSection}>
{exchange.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'} <div className={styles.timelineHeader}>
</MonoText> <span className={styles.timelineTitle}>
Processor Timeline
<span className={styles.procCount}>{exchange.processors.length} processors</span>
</span>
<div className={styles.timelineToggle}>
<button
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('gantt')}
>
Timeline
</button>
<button
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('flow')}
>
Flow
</button>
</div>
</div>
<div className={styles.timelineBody}>
{timelineView === 'gantt' ? (
<ProcessorTimeline
processors={exchange.processors}
totalMs={exchange.durationMs}
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
selectedIndex={selectedProcessorIndex}
/>
) : (
<RouteFlow
nodes={routeNodes}
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
selectedIndex={selectedProcessorIndex}
/>
)}
</div>
</div>
{/* Processor Detail Panel (split IN / OUT) */}
{selectedProc && snapshotIn && snapshotOut && (
<div className={styles.detailSplit}>
{/* Message IN */}
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowIn}>&rarr;</span> Message IN
</span>
<span className={styles.panelTag}>at processor #{selectedProcessorIndex + 1} entry</span>
</div>
<div className={styles.panelBody}>
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(snapshotIn.headers).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(snapshotIn.headers).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={snapshotIn.body} language="json" copyable />
</div>
</div> </div>
</div> </div>
{/* Message OUT or Error */}
{isSelectedFailed ? (
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowError}>&times;</span> Error at Processor #{selectedProcessorIndex + 1}
</span>
<Badge label="FAILED" color="error" variant="filled" />
</div>
<div className={styles.panelBody}>
{exchange.errorClass && (
<div className={styles.errorBadgeRow}>
<span className={styles.errorHttpBadge}>{exchange.errorClass.split('.').pop()}</span>
</div>
)}
{exchange.errorMessage && (
<div className={styles.errorMessageBox}>{exchange.errorMessage}</div>
)}
<div className={styles.errorDetailGrid}>
<span className={styles.errorDetailLabel}>Error Class</span>
<span className={styles.errorDetailValue}>{exchange.errorClass ?? 'Unknown'}</span>
<span className={styles.errorDetailLabel}>Processor</span>
<span className={styles.errorDetailValue}>{selectedProc.name}</span>
<span className={styles.errorDetailLabel}>Duration</span>
<span className={styles.errorDetailValue}>{formatDuration(selectedProc.durationMs)}</span>
<span className={styles.errorDetailLabel}>Status</span>
<span className={styles.errorDetailValue}>{selectedProc.status.toUpperCase()}</span>
</div>
</div>
</div>
) : (
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowOut}>&larr;</span> Message OUT
</span>
<span className={styles.panelTag}>after processor #{selectedProcessorIndex + 1}</span>
</div>
<div className={styles.panelBody}>
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(snapshotOut.headers).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(snapshotOut.headers).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={snapshotOut.body} language="json" copyable />
</div>
</div>
</div>
)}
</div> </div>
)} )}

View File

@@ -81,6 +81,21 @@
color: var(--text-primary); color: var(--text-primary);
} }
.navSubLink {
display: block;
font-size: 12px;
color: var(--text-muted);
text-decoration: none;
padding: 2px 8px 2px 20px;
border-radius: var(--radius-sm);
line-height: 1.5;
}
.navSubLink:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.content { .content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@@ -4,10 +4,88 @@ import { PrimitivesSection } from './sections/PrimitivesSection'
import { CompositesSection } from './sections/CompositesSection' import { CompositesSection } from './sections/CompositesSection'
import { LayoutSection } from './sections/LayoutSection' import { LayoutSection } from './sections/LayoutSection'
const NAV_ITEMS = [ const NAV_SECTIONS = [
{ label: 'Primitives', href: '#primitives' }, {
{ label: 'Composites', href: '#composites' }, label: 'Primitives',
{ label: 'Layout', href: '#layout' }, href: '#primitives',
components: [
{ label: 'Alert', href: '#alert' },
{ label: 'Avatar', href: '#avatar' },
{ label: 'Badge', href: '#badge' },
{ label: 'Button', href: '#button' },
{ label: 'ButtonGroup', href: '#buttongroup' },
{ label: 'Card', href: '#card' },
{ label: 'Checkbox', href: '#checkbox' },
{ label: 'CodeBlock', href: '#codeblock' },
{ label: 'Collapsible', href: '#collapsible' },
{ label: 'DateRangePicker', href: '#daterangepicker' },
{ label: 'DateTimePicker', href: '#datetimepicker' },
{ label: 'EmptyState', href: '#emptystate' },
{ label: 'FilterPill', href: '#filterpill' },
{ label: 'FormField', href: '#formfield' },
{ label: 'InfoCallout', href: '#infocallout' },
{ label: 'InlineEdit', href: '#inline-edit' },
{ label: 'Input', href: '#input' },
{ label: 'KeyboardHint', href: '#keyboardhint' },
{ label: 'Label', href: '#label' },
{ label: 'MonoText', href: '#monotext' },
{ label: 'Pagination', href: '#pagination' },
{ label: 'ProgressBar', href: '#progressbar' },
{ label: 'Radio', href: '#radio' },
{ label: 'SectionHeader', href: '#sectionheader' },
{ label: 'Select', href: '#select' },
{ label: 'Skeleton', href: '#skeleton' },
{ label: 'Sparkline', href: '#sparkline' },
{ label: 'Spinner', href: '#spinner' },
{ label: 'StatCard', href: '#statcard' },
{ label: 'StatusDot', href: '#statusdot' },
{ label: 'Tag', href: '#tag' },
{ label: 'Textarea', href: '#textarea' },
{ label: 'Toggle', href: '#toggle' },
{ label: 'Tooltip', href: '#tooltip' },
],
},
{
label: 'Composites',
href: '#composites',
components: [
{ label: 'Accordion', href: '#accordion' },
{ label: 'AlertDialog', href: '#alertdialog' },
{ label: 'AreaChart', href: '#areachart' },
{ label: 'AvatarGroup', href: '#avatargroup' },
{ label: 'BarChart', href: '#barchart' },
{ label: 'Breadcrumb', href: '#breadcrumb' },
{ label: 'CommandPalette', href: '#commandpalette' },
{ label: 'ConfirmDialog', href: '#confirm-dialog' },
{ label: 'DataTable', href: '#datatable' },
{ label: 'DetailPanel', href: '#detailpanel' },
{ label: 'Dropdown', href: '#dropdown' },
{ label: 'EventFeed', href: '#eventfeed' },
{ label: 'FilterBar', href: '#filterbar' },
{ label: 'GroupCard', href: '#groupcard' },
{ label: 'LineChart', href: '#linechart' },
{ label: 'MenuItem', href: '#menuitem' },
{ label: 'Modal', href: '#modal' },
{ label: 'MultiSelect', href: '#multi-select' },
{ label: 'Popover', href: '#popover' },
{ label: 'ProcessorTimeline', href: '#processortimeline' },
{ label: 'RouteFlow', href: '#routeflow' },
{ label: 'SegmentedTabs', href: '#segmented-tabs' },
{ label: 'ShortcutsBar', href: '#shortcutsbar' },
{ label: 'Tabs', href: '#tabs' },
{ label: 'Toast', href: '#toast' },
{ label: 'TreeView', href: '#treeview' },
],
},
{
label: 'Layout',
href: '#layout',
components: [
{ label: 'AppShell', href: '#appshell' },
{ label: 'Sidebar', href: '#sidebar' },
{ label: 'TopBar', href: '#topbar' },
],
},
] ]
export function Inventory() { export function Inventory() {
@@ -20,14 +98,16 @@ export function Inventory() {
<div className={styles.body}> <div className={styles.body}>
<nav className={styles.nav} aria-label="Component categories"> <nav className={styles.nav} aria-label="Component categories">
<div className={styles.navSection}> {NAV_SECTIONS.map((section) => (
<span className={styles.navLabel}>Categories</span> <div key={section.href} className={styles.navSection}>
{NAV_ITEMS.map((item) => ( <span className={styles.navLabel}>{section.label}</span>
<a key={item.href} href={item.href} className={styles.navLink}> {section.components.map((component) => (
{item.label} <a key={component.href} href={component.href} className={styles.navSubLink}>
</a> {component.label}
))} </a>
</div> ))}
</div>
))}
</nav> </nav>
<main className={styles.content}> <main className={styles.content}>

View File

@@ -8,6 +8,7 @@ import {
BarChart, BarChart,
Breadcrumb, Breadcrumb,
CommandPalette, CommandPalette,
ConfirmDialog,
DataTable, DataTable,
DetailPanel, DetailPanel,
Dropdown, Dropdown,
@@ -17,8 +18,11 @@ import {
LineChart, LineChart,
MenuItem, MenuItem,
Modal, Modal,
MultiSelect,
Popover, Popover,
ProcessorTimeline, ProcessorTimeline,
RouteFlow,
SegmentedTabs,
ShortcutsBar, ShortcutsBar,
Tabs, Tabs,
ToastProvider, ToastProvider,
@@ -208,6 +212,13 @@ export function CompositesSection() {
] ]
const [activeFilters, setActiveFilters] = useState([{ label: 'Live', value: 'live' }]) const [activeFilters, setActiveFilters] = useState([{ label: 'Live', value: 'live' }])
// ConfirmDialog
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmDone, setConfirmDone] = useState(false)
// MultiSelect
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
// 15. Modal // 15. Modal
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
@@ -218,6 +229,7 @@ export function CompositesSection() {
{ label: 'Agents', value: 'agents', count: 6 }, { label: 'Agents', value: 'agents', count: 6 },
] ]
const [activeTab, setActiveTab] = useState('overview') const [activeTab, setActiveTab] = useState('overview')
const [segTab, setSegTab] = useState('account')
// 21. TreeView // 21. TreeView
const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1') const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1')
@@ -294,6 +306,21 @@ export function CompositesSection() {
/> />
</DemoCard> </DemoCard>
{/* 2b. ConfirmDialog */}
<DemoCard id="confirm-dialog" title="ConfirmDialog" description="Type-to-confirm destructive action dialog. Built on Modal.">
<Button size="sm" variant="danger" onClick={() => { setConfirmOpen(true); setConfirmDone(false) }}>
Delete project
</Button>
{confirmDone && <span style={{ color: 'var(--success)', fontSize: 12, marginLeft: 8 }}>Deleted!</span>}
<ConfirmDialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => { setConfirmOpen(false); setConfirmDone(true) }}
message={'Delete project "my-project"? This cannot be undone.'}
confirmText="my-project"
/>
</DemoCard>
{/* 3. AreaChart */} {/* 3. AreaChart */}
<DemoCard <DemoCard
id="areachart" id="areachart"
@@ -511,6 +538,27 @@ export function CompositesSection() {
</Modal> </Modal>
</DemoCard> </DemoCard>
{/* 15b. MultiSelect */}
<DemoCard id="multi-select" title="MultiSelect" description="Dropdown with searchable checkbox list and Apply action.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
<MultiSelect
options={[
{ value: 'admin', label: 'ADMIN' },
{ value: 'editor', label: 'EDITOR' },
{ value: 'viewer', label: 'VIEWER' },
{ value: 'operator', label: 'OPERATOR' },
{ value: 'auditor', label: 'AUDITOR' },
]}
value={multiValue}
onChange={setMultiValue}
placeholder="Add roles..."
/>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Selected: {multiValue.join(', ') || 'none'}
</span>
</div>
</DemoCard>
{/* 16. Popover */} {/* 16. Popover */}
<DemoCard <DemoCard
id="popover" id="popover"
@@ -560,6 +608,28 @@ export function CompositesSection() {
</div> </div>
</DemoCard> </DemoCard>
{/* 17b. RouteFlow */}
<DemoCard
id="routeflow"
title="RouteFlow"
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
>
<div style={{ width: '100%', maxWidth: 360 }}>
<RouteFlow
nodes={[
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
{ name: 'sql:INSERT INTO orders', type: 'to', durationMs: 24, status: 'ok' },
{ name: 'header.priority == HIGH', type: 'choice', durationMs: 1, status: 'ok' },
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow', isBottleneck: true },
{ name: 'ResponseMapper', type: 'process', durationMs: 3, status: 'ok' },
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
]}
/>
</div>
</DemoCard>
{/* 18. ShortcutsBar */} {/* 18. ShortcutsBar */}
<DemoCard <DemoCard
id="shortcutsbar" id="shortcutsbar"
@@ -591,6 +661,28 @@ export function CompositesSection() {
</div> </div>
</DemoCard> </DemoCard>
{/* 19b. SegmentedTabs */}
<DemoCard
id="segmented-tabs"
title="SegmentedTabs"
description="Pill-style segmented tab bar with elevated active state. Same API as Tabs."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<SegmentedTabs
tabs={[
{ label: 'Account', value: 'account' },
{ label: 'Password', value: 'password' },
{ label: 'Notifications', value: 'notifications', count: 3 },
]}
active={segTab}
onChange={setSegTab}
/>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Active tab: <strong>{segTab}</strong>
</div>
</div>
</DemoCard>
{/* 20. Toast */} {/* 20. Toast */}
<DemoCard <DemoCard
id="toast" id="toast"

View File

@@ -76,7 +76,7 @@ export function LayoutSection() {
> >
<div className={styles.shellDiagram}> <div className={styles.shellDiagram}>
<div className={styles.shellDiagramTop}> <div className={styles.shellDiagramTop}>
TopBar breadcrumb · search · env badge · shift · user avatar TopBar breadcrumb · search · filters · time range · env badge · user avatar
</div> </div>
<div className={styles.shellDiagramBody}> <div className={styles.shellDiagramBody}>
<div className={styles.shellDiagramSide}> <div className={styles.shellDiagramSide}>
@@ -110,7 +110,7 @@ export function LayoutSection() {
<DemoCard <DemoCard
id="topbar" id="topbar"
title="TopBar" title="TopBar"
description="Top navigation bar with breadcrumb, search trigger, environment badge, shift info, and user avatar." description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar."
> >
<div className={styles.topbarPreview}> <div className={styles.topbarPreview}>
<TopBar <TopBar

View File

@@ -5,6 +5,7 @@ import {
Avatar, Avatar,
Badge, Badge,
Button, Button,
ButtonGroup,
Card, Card,
Checkbox, Checkbox,
CodeBlock, CodeBlock,
@@ -15,6 +16,7 @@ import {
FilterPill, FilterPill,
FormField, FormField,
InfoCallout, InfoCallout,
InlineEdit,
Input, Input,
KeyboardHint, KeyboardHint,
Label, Label,
@@ -71,6 +73,9 @@ export function PrimitivesSection() {
// Alert state // Alert state
const [alertDismissed, setAlertDismissed] = useState(false) const [alertDismissed, setAlertDismissed] = useState(false)
// ButtonGroup state
const [bgSelection, setBgSelection] = useState<Set<string>>(new Set(['warn']))
// Checkbox state // Checkbox state
const [checked1, setChecked1] = useState(false) const [checked1, setChecked1] = useState(false)
const [checked2, setChecked2] = useState(true) const [checked2, setChecked2] = useState(true)
@@ -95,6 +100,9 @@ export function PrimitivesSection() {
end: new Date('2026-03-18T23:59'), end: new Date('2026-03-18T23:59'),
}) })
// InlineEdit state
const [inlineValue, setInlineValue] = useState('Alice Johnson')
return ( return (
<section id="primitives" className={styles.section}> <section id="primitives" className={styles.section}>
<h2 className={styles.sectionTitle}>Primitives</h2> <h2 className={styles.sectionTitle}>Primitives</h2>
@@ -174,6 +182,24 @@ export function PrimitivesSection() {
</div> </div>
</DemoCard> </DemoCard>
{/* 4b. ButtonGroup */}
<DemoCard
id="buttongroup"
title="ButtonGroup"
description="Multi-select toggle group with optional colored dot indicators. Used for status filters."
>
<ButtonGroup
items={[
{ value: 'ok', label: 'OK', color: 'var(--success)' },
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
{ value: 'error', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]}
value={bgSelection}
onChange={setBgSelection}
/>
</DemoCard>
{/* 5. Card */} {/* 5. Card */}
<DemoCard <DemoCard
id="card" id="card"
@@ -328,6 +354,15 @@ export function PrimitivesSection() {
<Input icon="🔍" placeholder="With icon" /> <Input icon="🔍" placeholder="With icon" />
</DemoCard> </DemoCard>
{/* 15b. InlineEdit */}
<DemoCard id="inline-edit" title="InlineEdit" description="Click-to-edit text field. Enter saves, Escape/blur cancels.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<InlineEdit value={inlineValue} onSave={setInlineValue} />
<InlineEdit value="" onSave={() => {}} placeholder="Click to add name..." />
<InlineEdit value="Read only" onSave={() => {}} disabled />
</div>
</DemoCard>
{/* 16. KeyboardHint */} {/* 16. KeyboardHint */}
<DemoCard <DemoCard
id="keyboardhint" id="keyboardhint"

View File

@@ -1,134 +0,0 @@
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* KPI strip */
.kpiStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 16px;
}
/* Route performance table */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 20px;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Route name in table */
.routeNameCell {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
}
/* Rate color classes */
.rateGood {
color: var(--success);
}
.rateWarn {
color: var(--warning);
}
.rateBad {
color: var(--error);
}
.rateNeutral {
color: var(--text-secondary);
}
/* 2x2 chart grid */
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.chartTitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.chart {
width: 100%;
}

View File

@@ -1,307 +0,0 @@
import { useNavigate } from 'react-router-dom'
import styles from './Metrics.module.css'
// Layout
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites
import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart'
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
import { BarChart } from '../../design-system/composites/BarChart/BarChart'
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types'
// Primitives
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge'
// Mock data
import {
throughputSeries,
latencySeries,
errorCountSeries,
routeMetrics,
type RouteMetricRow,
} from '../../mocks/metrics'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
// ─── Metrics KPI cards (5 cards per spec) ─────────────────────────────────────
const METRIC_KPIS = [
{
label: 'Throughput',
value: '47.2',
unit: 'msg/s',
trend: 'neutral' as const,
trendValue: '→',
detail: 'Capacity: 120 msg/s · 39%',
accent: 'running' as const,
sparkline: [44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47],
},
{
label: 'Latency p99',
value: '287ms',
trend: 'up' as const,
trendValue: '+23ms',
detail: 'SLA: <300ms · CLOSE',
accent: 'warning' as const,
sparkline: [198, 212, 205, 218, 224, 231, 238, 245, 252, 261, 268, 275, 281, 287],
},
{
label: 'Error Rate',
value: '2.9%',
trend: 'up' as const,
trendValue: '+0.4%',
detail: '38 errors this shift',
accent: 'error' as const,
sparkline: [1.2, 1.8, 1.5, 2.1, 2.4, 2.2, 2.5, 2.6, 2.7, 2.8, 2.7, 2.9, 2.8, 2.9],
},
{
label: 'Success Rate',
value: '97.1%',
trend: 'down' as const,
trendValue: '-0.4%',
detail: '3,147 ok · 56 warn · 38 err',
accent: 'success' as const,
sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1],
},
{
label: 'Active Routes',
value: 7,
trend: 'neutral' as const,
trendValue: '→',
detail: '4 healthy · 2 degraded · 1 stale',
accent: 'amber' as const,
sparkline: [7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7],
},
]
// ─── Route metric row with id field (required by DataTable) ──────────────────
type RouteMetricRowWithId = RouteMetricRow & { id: string }
const routeMetricsWithId: RouteMetricRowWithId[] = routeMetrics.map((r) => ({
...r,
id: r.routeId,
}))
// ─── Route performance table columns ──────────────────────────────────────────
const ROUTE_COLUMNS: Column<RouteMetricRowWithId>[] = [
{
key: 'routeName',
header: 'Route',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.routeName}</span>
),
},
{
key: 'exchangeCount',
header: 'Exchanges',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
),
},
{
key: 'successRate',
header: 'Success %',
sortable: true,
render: (_, row) => {
const cls = row.successRate >= 99 ? styles.rateGood : row.successRate >= 97 ? styles.rateWarn : styles.rateBad
return <MonoText size="sm" className={cls}>{row.successRate.toFixed(1)}%</MonoText>
},
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.avgDurationMs}ms</MonoText>
),
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood
return <MonoText size="sm" className={cls}>{row.p99DurationMs}ms</MonoText>
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? styles.rateBad : styles.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
]
// ─── Build bar chart data from error series ────────────────────────────────────
function buildErrorBarSeries() {
// Take every 5th point and format x as time label
const sampleInterval = 5
return errorCountSeries.map((s) => ({
label: s.label,
data: s.data
.filter((_, i) => i % sampleInterval === 0)
.map((pt) => ({
x: pt.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
y: Math.round(pt.value),
})),
}))
}
// ─── Build volume area chart (derived from throughput) ─────────────────────────
function buildVolumeSeries() {
return throughputSeries.map((s) => ({
label: s.label,
data: s.data.map((pt) => ({
x: pt.timestamp,
y: Math.round(pt.value * 60), // approx msg/min
})),
}))
}
const ERROR_BAR_SERIES = buildErrorBarSeries()
const VOLUME_SERIES = buildVolumeSeries()
// Convert MetricSeries (from mocks) to ChartSeries format
function convertSeries(series: typeof throughputSeries) {
return series.map((s) => ({
label: s.label,
data: s.data.map((pt) => ({ x: pt.timestamp, y: pt.value })),
}))
}
// ─── Metrics page ─────────────────────────────────────────────────────────────
export function Metrics() {
const navigate = useNavigate()
return (
<AppShell
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/apps' },
{ label: 'Metrics' },
]}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Auto-refresh indicator */}
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI stat cards (5) */}
<div className={styles.kpiStrip}>
{METRIC_KPIS.map((kpi, i) => (
<StatCard
key={i}
label={kpi.label}
value={kpi.value}
detail={kpi.detail}
trend={kpi.trend}
trendValue={kpi.trendValue}
accent={kpi.accent}
sparkline={kpi.sparkline}
/>
))}
</div>
{/* Per-route performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Per-Route Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{routeMetrics.length} routes</span>
<Badge label="SHIFT" color="primary" />
</div>
</div>
<DataTable
columns={ROUTE_COLUMNS}
data={routeMetricsWithId}
sortable
onRowClick={(row) => navigate(`/routes/${row.routeId}`)}
/>
</div>
{/* 2x2 chart grid */}
<div className={styles.chartGrid}>
{/* Throughput area chart */}
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<AreaChart
series={convertSeries(throughputSeries)}
yLabel="msg/s"
height={200}
width={500}
className={styles.chart}
/>
</div>
{/* Latency line chart with SLA threshold */}
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency (ms)</div>
<LineChart
series={convertSeries(latencySeries)}
yLabel="ms"
threshold={{ value: 300, label: 'SLA 300ms' }}
height={200}
width={500}
className={styles.chart}
/>
</div>
{/* Error bar chart */}
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors by Route</div>
<BarChart
series={ERROR_BAR_SERIES}
stacked
height={200}
width={500}
className={styles.chart}
/>
</div>
{/* Volume area chart */}
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
<AreaChart
series={VOLUME_SERIES}
yLabel="msg/min"
height={200}
width={500}
className={styles.chart}
/>
</div>
</div>
</div>
</AppShell>
)
}

View File

@@ -0,0 +1,359 @@
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* KPI strip */
.kpiStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
/* KPI card */
.kpiCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 16px 18px 12px;
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
}
.kpiCard:hover {
box-shadow: var(--shadow-md);
}
.kpiCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.kpiCardAmber::before { background: linear-gradient(90deg, var(--amber), transparent); }
.kpiCardGreen::before { background: linear-gradient(90deg, var(--success), transparent); }
.kpiCardError::before { background: linear-gradient(90deg, var(--error), transparent); }
.kpiCardTeal::before { background: linear-gradient(90deg, var(--running), transparent); }
.kpiCardWarn::before { background: linear-gradient(90deg, var(--warning), transparent); }
.kpiLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 6px;
}
.kpiValueRow {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
}
.kpiValue {
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
}
.kpiValueAmber { color: var(--amber); }
.kpiValueGreen { color: var(--success); }
.kpiValueError { color: var(--error); }
.kpiValueTeal { color: var(--running); }
.kpiValueWarn { color: var(--warning); }
.kpiUnit {
font-size: 12px;
color: var(--text-muted);
}
.kpiTrend {
font-family: var(--font-mono);
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.trendUpGood { color: var(--success); }
.trendUpBad { color: var(--error); }
.trendDownGood { color: var(--success); }
.trendDownBad { color: var(--error); }
.trendFlat { color: var(--text-muted); }
.kpiDetail {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.kpiDetailStrong {
color: var(--text-secondary);
font-weight: 600;
}
.kpiSparkline {
margin-top: 8px;
height: 32px;
}
/* Latency percentiles card */
.latencyValues {
display: flex;
gap: 12px;
margin-bottom: 4px;
}
.latencyItem {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.latencyLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.latencyVal {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 600;
line-height: 1.2;
}
.latValGreen { color: var(--success); }
.latValAmber { color: var(--amber); }
.latValRed { color: var(--error); }
.latencyTrend {
font-family: var(--font-mono);
font-size: 9px;
}
/* Active routes donut */
.donutWrap {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.donutLabel {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.donutLegend {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 10px;
color: var(--text-muted);
}
.donutLegendActive {
color: var(--running);
font-weight: 600;
}
/* Route performance table */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 20px;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Route name in table */
.routeNameCell {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
}
/* Rate color classes */
.rateGood {
color: var(--success);
}
.rateWarn {
color: var(--warning);
}
.rateBad {
color: var(--error);
}
.rateNeutral {
color: var(--text-secondary);
}
/* 2x2 chart grid */
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.chartTitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.chart {
width: 100%;
}
/* Processor type badges */
.processorType {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.typeConsumer {
background: var(--running-bg);
color: var(--running);
}
.typeProducer {
background: var(--success-bg);
color: var(--success);
}
.typeEnricher {
background: var(--amber-bg);
color: var(--amber);
}
.typeValidator {
background: var(--running-bg);
color: var(--running);
}
.typeTransformer {
background: var(--bg-hover);
color: var(--text-muted);
}
.typeRouter {
background: var(--purple-bg);
color: var(--purple);
}
.typeProcessor {
background: var(--bg-hover);
color: var(--text-secondary);
}
/* Route flow section */
.routeFlowSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-top: 16px;
}
/* Application column in table */
.appCell {
font-size: 12px;
color: var(--text-secondary);
}

595
src/pages/Routes/Routes.tsx Normal file
View File

@@ -0,0 +1,595 @@
import { useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import styles from './Routes.module.css'
// Layout
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites
import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart'
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
import { BarChart } from '../../design-system/composites/BarChart/BarChart'
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types'
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
// Primitives
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge'
// Mock data
import {
throughputSeries,
latencySeries,
errorCountSeries,
routeMetrics,
type RouteMetricRow,
} from '../../mocks/metrics'
import { routes } from '../../mocks/routes'
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
const ROUTE_TO_APP = buildRouteToAppMap()
// ─── KPI Header Strip (matches mock-v3-metrics-dashboard) ────────────────────
function KpiHeader({ scopedMetrics }: { scopedMetrics: RouteMetricRow[] }) {
const totalExchanges = scopedMetrics.reduce((sum, r) => sum + r.exchangeCount, 0)
const totalErrors = scopedMetrics.reduce((sum, r) => sum + r.errorCount, 0)
const errorRate = totalExchanges > 0 ? ((totalErrors / totalExchanges) * 100) : 0
const avgLatency = scopedMetrics.length > 0
? Math.round(scopedMetrics.reduce((sum, r) => sum + r.avgDurationMs, 0) / scopedMetrics.length)
: 0
const p99Latency = scopedMetrics.length > 0
? Math.max(...scopedMetrics.map((r) => r.p99DurationMs))
: 0
const avgSuccessRate = scopedMetrics.length > 0
? Number((scopedMetrics.reduce((sum, r) => sum + r.successRate, 0) / scopedMetrics.length).toFixed(1))
: 0
const throughputPerSec = totalExchanges > 0 ? (totalExchanges / 360).toFixed(1) : '0'
const activeRoutes = scopedMetrics.length
const totalRoutes = routeMetrics.length
return (
<div className={styles.kpiStrip}>
{/* Card 1: Total Throughput */}
<div className={`${styles.kpiCard} ${styles.kpiCardAmber}`}>
<div className={styles.kpiLabel}>Total Throughput</div>
<div className={styles.kpiValueRow}>
<span className={`${styles.kpiValue} ${styles.kpiValueAmber}`}>{totalExchanges.toLocaleString()}</span>
<span className={styles.kpiUnit}>exchanges</span>
<span className={`${styles.kpiTrend} ${styles.trendUpGood}`}>&#9650; +8%</span>
</div>
<div className={styles.kpiDetail}>
<span className={styles.kpiDetailStrong}>{throughputPerSec}</span> msg/s · Capacity 39%
</div>
<div className={styles.kpiSparkline}>
<Sparkline data={[44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47]} color="var(--amber)" width={200} height={32} />
</div>
</div>
{/* Card 2: System Error Rate */}
<div className={`${styles.kpiCard} ${errorRate < 1 ? styles.kpiCardGreen : styles.kpiCardError}`}>
<div className={styles.kpiLabel}>System Error Rate</div>
<div className={styles.kpiValueRow}>
<span className={`${styles.kpiValue} ${errorRate < 1 ? styles.kpiValueGreen : styles.kpiValueError}`}>{errorRate.toFixed(2)}%</span>
<span className={`${styles.kpiTrend} ${errorRate < 1 ? styles.trendDownGood : styles.trendUpBad}`}>
{errorRate < 1 ? '\u25BC -0.1%' : '\u25B2 +0.4%'}
</span>
</div>
<div className={styles.kpiDetail}>
<span className={styles.kpiDetailStrong}>{totalErrors}</span> errors / <span className={styles.kpiDetailStrong}>{totalExchanges.toLocaleString()}</span> total (6h)
</div>
<div className={styles.kpiSparkline}>
<Sparkline data={[1.2, 1.8, 1.5, 2.1, 2.4, 2.2, 2.5, 2.6, 2.7, 2.8, 2.7, 2.9, 2.8, errorRate]} color={errorRate < 1 ? 'var(--success)' : 'var(--error)'} width={200} height={32} />
</div>
</div>
{/* Card 3: Latency Percentiles */}
<div className={`${styles.kpiCard} ${p99Latency > 300 ? styles.kpiCardWarn : styles.kpiCardGreen}`}>
<div className={styles.kpiLabel}>Latency Percentiles</div>
<div className={styles.latencyValues}>
<div className={styles.latencyItem}>
<span className={styles.latencyLabel}>P50</span>
<span className={`${styles.latencyVal} ${styles.latValGreen}`}>{Math.round(avgLatency * 0.5)}ms</span>
<span className={`${styles.latencyTrend} ${styles.trendDownGood}`}>&#9660;3</span>
</div>
<div className={styles.latencyItem}>
<span className={styles.latencyLabel}>P95</span>
<span className={`${styles.latencyVal} ${avgLatency > 150 ? styles.latValAmber : styles.latValGreen}`}>{Math.round(avgLatency * 1.4)}ms</span>
<span className={`${styles.latencyTrend} ${styles.trendUpBad}`}>&#9650;12</span>
</div>
<div className={styles.latencyItem}>
<span className={styles.latencyLabel}>P99</span>
<span className={`${styles.latencyVal} ${p99Latency > 300 ? styles.latValRed : styles.latValAmber}`}>{p99Latency}ms</span>
<span className={`${styles.latencyTrend} ${styles.trendUpBad}`}>&#9650;28</span>
</div>
</div>
<div className={styles.kpiDetail}>
SLA: &lt;300ms P99 · {p99Latency > 300
? <span style={{ color: 'var(--error)', fontWeight: 600 }}>BREACH</span>
: <span style={{ color: 'var(--success)', fontWeight: 600 }}>OK</span>}
</div>
</div>
{/* Card 4: Active Routes */}
<div className={`${styles.kpiCard} ${styles.kpiCardTeal}`}>
<div className={styles.kpiLabel}>Active Routes</div>
<div className={styles.kpiValueRow}>
<span className={`${styles.kpiValue} ${styles.kpiValueTeal}`}>{activeRoutes}</span>
<span className={styles.kpiUnit}>of {totalRoutes}</span>
<span className={`${styles.kpiTrend} ${styles.trendFlat}`}>&#8596; stable</span>
</div>
<div className={styles.donutWrap}>
<svg viewBox="0 0 36 36" width="40" height="40">
<circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--bg-inset)" strokeWidth="3" />
<circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--running)" strokeWidth="3"
strokeDasharray={`${(activeRoutes / totalRoutes) * 100} ${100 - (activeRoutes / totalRoutes) * 100}`}
strokeDashoffset="25" strokeLinecap="round" />
</svg>
<div className={styles.donutLegend}>
<span className={styles.donutLegendActive}>{activeRoutes} active</span>
<span>{totalRoutes - activeRoutes} stopped</span>
</div>
</div>
</div>
{/* Card 5: In-Flight Exchanges */}
<div className={`${styles.kpiCard} ${styles.kpiCardAmber}`}>
<div className={styles.kpiLabel}>In-Flight Exchanges</div>
<div className={styles.kpiValueRow}>
<span className={styles.kpiValue}>23</span>
<span className={`${styles.kpiTrend} ${styles.trendFlat}`}>&#8596;</span>
</div>
<div className={styles.kpiDetail}>
High-water: <span className={styles.kpiDetailStrong}>67</span> (2h ago)
</div>
<div className={styles.kpiSparkline}>
<Sparkline data={[16, 14, 18, 12, 10, 15, 8, 6, 4, 3, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 18, 16, 18, 20, 18, 23]} color="var(--amber)" width={200} height={32} />
</div>
</div>
</div>
)
}
// ─── Route metric row with id field (required by DataTable) ──────────────────
type RouteMetricRowWithId = RouteMetricRow & { id: string }
// ─── Processor metrics types and generator ───────────────────────────────────
interface ProcessorMetric {
name: string
type: string
invocations: number
avgDurationMs: number
p99DurationMs: number
errorCount: number
errorRate: number
sparkline: number[]
}
type ProcessorMetricWithId = ProcessorMetric & { id: string }
function generateProcessorMetrics(processors: string[], routeExchangeCount: number): ProcessorMetric[] {
return processors.map((proc, i) => {
const name = proc
const type = proc.startsWith('from(') ? 'consumer'
: proc.startsWith('to(') ? 'producer'
: proc.startsWith('enrich(') ? 'enricher'
: proc.startsWith('validate(') || proc.startsWith('check(') ? 'validator'
: proc.startsWith('unmarshal(') || proc.startsWith('marshal(') ? 'transformer'
: proc.startsWith('route(') || proc.startsWith('choice(') ? 'router'
: 'processor'
const invocations = routeExchangeCount
const avgBase = 10 + (i * 15) + (proc.includes('enrich') ? 40 : 0) + (proc.includes('http') ? 80 : 0)
const avgDurationMs = avgBase + Math.round(Math.sin(i * 2.1) * 10)
const p99DurationMs = Math.round(avgDurationMs * 2.5)
const errorCount = proc.includes('enrich') || proc.includes('http') ? Math.round(invocations * 0.01) : Math.round(invocations * 0.001)
const errorRate = Number(((errorCount / invocations) * 100).toFixed(2))
const sparkline = Array.from({ length: 14 }, (_, j) => avgDurationMs + Math.round(Math.sin(j * 0.8 + i) * avgDurationMs * 0.15))
return { name, type, invocations, avgDurationMs, p99DurationMs, errorCount, errorRate, sparkline }
})
}
// ─── Map processor type to RouteNode type ────────────────────────────────────
function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'producer': return 'to'
case 'enricher': return 'process'
case 'validator': return 'process'
case 'transformer': return 'process'
case 'router': return 'choice'
default: return 'process'
}
}
// ─── Processor type badge classes ────────────────────────────────────────────
const TYPE_STYLE_MAP: Record<string, string> = {
consumer: styles.typeConsumer,
producer: styles.typeProducer,
enricher: styles.typeEnricher,
validator: styles.typeValidator,
transformer: styles.typeTransformer,
router: styles.typeRouter,
processor: styles.typeProcessor,
}
// ─── Route performance table columns ──────────────────────────────────────────
const ROUTE_COLUMNS: Column<RouteMetricRowWithId>[] = [
{
key: 'routeName',
header: 'Route',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.routeName}</span>
),
},
{
key: 'appId',
header: 'Application',
sortable: true,
render: (_, row) => (
<span className={styles.appCell}>{row.appId}</span>
),
},
{
key: 'exchangeCount',
header: 'Exchanges',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
),
},
{
key: 'successRate',
header: 'Success %',
sortable: true,
render: (_, row) => {
const cls = row.successRate >= 99 ? styles.rateGood : row.successRate >= 97 ? styles.rateWarn : styles.rateBad
return <MonoText size="sm" className={cls}>{row.successRate.toFixed(1)}%</MonoText>
},
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.avgDurationMs}ms</MonoText>
),
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood
return <MonoText size="sm" className={cls}>{row.p99DurationMs}ms</MonoText>
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? styles.rateBad : styles.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
]
// ─── Processor performance table columns ─────────────────────────────────────
const PROCESSOR_COLUMNS: Column<ProcessorMetricWithId>[] = [
{
key: 'name',
header: 'Processor',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.name}</span>
),
},
{
key: 'type',
header: 'Type',
sortable: true,
render: (_, row) => (
<span className={`${styles.processorType} ${TYPE_STYLE_MAP[row.type] ?? styles.typeProcessor}`}>
{row.type}
</span>
),
},
{
key: 'invocations',
header: 'Invocations',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.invocations.toLocaleString()}</MonoText>
),
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => {
const cls = row.avgDurationMs > 200 ? styles.rateBad : row.avgDurationMs > 100 ? styles.rateWarn : styles.rateGood
return <MonoText size="sm" className={cls}>{row.avgDurationMs}ms</MonoText>
},
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood
return <MonoText size="sm" className={cls}>{row.p99DurationMs}ms</MonoText>
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? styles.rateBad : styles.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
key: 'errorRate',
header: 'Error Rate',
sortable: true,
render: (_, row) => {
const cls = row.errorRate > 1 ? styles.rateBad : row.errorRate > 0.5 ? styles.rateWarn : styles.rateGood
return <MonoText size="sm" className={cls}>{row.errorRate}%</MonoText>
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
]
// ─── Build bar chart data from error series ────────────────────────────────────
function buildErrorBarSeries() {
const sampleInterval = 5
return errorCountSeries.map((s) => ({
label: s.label,
data: s.data
.filter((_, i) => i % sampleInterval === 0)
.map((pt) => ({
x: pt.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
y: Math.round(pt.value),
})),
}))
}
// ─── Build volume area chart (derived from throughput) ─────────────────────────
function buildVolumeSeries() {
return throughputSeries.map((s) => ({
label: s.label,
data: s.data.map((pt) => ({
x: pt.timestamp,
y: Math.round(pt.value * 60),
})),
}))
}
const ERROR_BAR_SERIES = buildErrorBarSeries()
const VOLUME_SERIES = buildVolumeSeries()
// Convert MetricSeries (from mocks) to ChartSeries format
function convertSeries(series: typeof throughputSeries) {
return series.map((s) => ({
label: s.label,
data: s.data.map((pt) => ({ x: pt.timestamp, y: pt.value })),
}))
}
// ─── Routes page ──────────────────────────────────────────────────────────────
export function Routes() {
const navigate = useNavigate()
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
// ── Breadcrumbs ─────────────────────────────────────────────────────────────
const breadcrumb = useMemo(() => {
if (routeId && appId) {
return [
{ label: 'Routes', href: '/routes' },
{ label: appId, href: `/routes/${appId}` },
{ label: routeId },
]
}
if (appId) {
return [
{ label: 'Routes', href: '/routes' },
{ label: appId },
]
}
return [{ label: 'Routes' }]
}, [appId, routeId])
// ── Data filtering ──────────────────────────────────────────────────────────
const filteredMetrics = useMemo(() => {
const data = appId
? routeMetrics.filter((r) => r.appId === appId)
: routeMetrics
return data.map((r) => ({ ...r, id: r.routeId }))
}, [appId])
// ── Route detail data ───────────────────────────────────────────────────────
const routeDef = useMemo(() => {
if (!routeId) return null
return routes.find((r) => r.id === routeId) ?? null
}, [routeId])
const processorMetrics = useMemo<ProcessorMetricWithId[]>(() => {
if (!routeDef) return []
return generateProcessorMetrics(routeDef.processors, routeDef.exchangeCount).map((pm, i) => ({
...pm,
id: `proc-${i}`,
}))
}, [routeDef])
const routeFlowNodes = useMemo<RouteNode[]>(() => {
if (!processorMetrics.length) return []
return processorMetrics.map((pm) => ({
name: pm.name,
type: toRouteNodeType(pm.type),
durationMs: pm.avgDurationMs,
status: pm.errorRate > 1 ? 'fail' as const : pm.avgDurationMs > 150 ? 'slow' as const : 'ok' as const,
}))
}, [processorMetrics])
// Scoped metrics for KPI header
const scopedMetricsForKpi = useMemo(() => {
if (routeId) return routeMetrics.filter((r) => r.routeId === routeId)
if (appId) return routeMetrics.filter((r) => r.appId === appId)
return routeMetrics
}, [appId, routeId])
// ── Route detail view ───────────────────────────────────────────────────────
if (routeId && appId && routeDef) {
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={breadcrumb}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
<KpiHeader scopedMetrics={scopedMetricsForKpi} />
{/* Processor Performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Processor Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{processorMetrics.length} processors</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={PROCESSOR_COLUMNS}
data={processorMetrics}
sortable
/>
</div>
{/* Route Flow diagram */}
<div className={styles.routeFlowSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Flow</span>
</div>
<RouteFlow nodes={routeFlowNodes} />
</div>
</div>
</AppShell>
)
}
// ── Top level / Application level view ──────────────────────────────────────
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={breadcrumb}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI header cards */}
<KpiHeader scopedMetrics={scopedMetricsForKpi} />
{/* Per-route performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Per-Route Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{filteredMetrics.length} routes</span>
<Badge label="SHIFT" color="primary" />
</div>
</div>
<DataTable
columns={ROUTE_COLUMNS}
data={filteredMetrics}
sortable
onRowClick={(row) => {
const rowAppId = appId ?? ROUTE_TO_APP.get(row.routeId) ?? row.routeId
navigate(`/routes/${rowAppId}/${row.routeId}`)
}}
/>
</div>
{/* 2x2 chart grid */}
<div className={styles.chartGrid}>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<AreaChart
series={convertSeries(throughputSeries)}
yLabel="msg/s"
height={200}
width={500}
className={styles.chart}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency (ms)</div>
<LineChart
series={convertSeries(latencySeries)}
yLabel="ms"
threshold={{ value: 300, label: 'SLA 300ms' }}
height={200}
width={500}
className={styles.chart}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors by Route</div>
<BarChart
series={ERROR_BAR_SERIES}
stacked
height={200}
width={500}
className={styles.chart}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
<AreaChart
series={VOLUME_SERIES}
yLabel="msg/min"
height={200}
width={500}
className={styles.chart}
/>
</div>
</div>
</div>
</AppShell>
)
}