Compare commits

...

39 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
hsiegeln
dd4e01d6a7 docs: update guides for ButtonGroup, DataTable flush, FilterPill forwardRef
All checks were successful
Build & Publish / publish (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:26:36 +01:00
hsiegeln
c412b3fb63 fix: add flush prop to DataTable to remove rounded corners when embedded
All checks were successful
Build & Publish / publish (push) Successful in 42s
The DataTable wrapper's border-radius created visible gaps when nested
inside a parent container (e.g. tableSection) that already provides its
own border and radius. The new flush prop strips border, radius, and
shadow so the table sits flush against its container.

Applied in Dashboard and RouteDetail "Recent Exchanges" tables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:27 +01:00
hsiegeln
f16c5a9575 feat: add ButtonGroup primitive and redesign TopBar time range selector
All checks were successful
Build & Publish / publish (push) Successful in 46s
Replace the TimeRangeDropdown popover with inline FilterPills inside a
new ButtonGroup component. The ButtonGroup merges adjacent children into
a single visual strip with shared borders and rounded end-caps.

The time readout is now an integrated inset display cell at the right end
of the group. Preset ranges show "HH:MM – now"; custom ranges show both
timestamps. Default changed from 3h to 1h.

TopBar reordered to: Breadcrumb | Search | Status pills | Time pills | Right.
FilterPill upgraded to forwardRef with data-active attribute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:18:57 +01:00
hsiegeln
0a3d568a47 fix: add CSS module type declarations for dts generation
All checks were successful
Build & Publish / publish (push) Successful in 41s
vite-plugin-dts runs tsc to generate declarations and needs explicit
type definitions for .module.css, .css, and .svg imports. Without
these, the DTS step fails with TS2307 for every CSS module import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:41:54 +01:00
hsiegeln
5053780dc9 fix(ci): use ubuntu-latest runner label to match Gitea runner
All checks were successful
Build & Publish / publish (push) Successful in 48s
The runner is registered as ubuntu-latest, not linux-arm64.
The underlying hardware is ARM64 but the label must match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:38:37 +01:00
89 changed files with 12336 additions and 1296 deletions

View File

@@ -7,7 +7,7 @@ on:
jobs: jobs:
publish: publish:
runs-on: linux-arm64 runs-on: ubuntu-latest
container: container:
image: node:22-bookworm-slim image: node:22-bookworm-slim
steps: steps:
@@ -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
refs/tags/v*)
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version npm version "$VERSION" --no-git-tag-version
TAG="latest" TAG="latest"
else ;;
*)
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7) SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
DATE=$(date +%Y%m%d) DATE=$(date +%Y%m%d)
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
TAG="dev" TAG="dev"
fi ;;
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

@@ -23,7 +23,7 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
- No inline styles except dynamic values (width from props, etc.) - No inline styles except dynamic values (width from props, etc.)
### Components ### Components
- `forwardRef` on all form controls (Input, Textarea, Select, Checkbox, Toggle, Label) - `forwardRef` on all form controls (Input, Textarea, Select, Checkbox, Toggle, Label, FilterPill)
- Every component accepts a `className` prop - Every component accepts a `className` prop
- Semantic color variants: `'success' | 'warning' | 'error'` pattern - Semantic color variants: `'success' | 'warning' | 'error'` pattern
- Barrel exports: `src/design-system/primitives/index.ts` and `src/design-system/composites/index.ts` - Barrel exports: `src/design-system/primitives/index.ts` and `src/design-system/composites/index.ts`

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,9 +78,15 @@
- Single user avatar → **Avatar** - Single user avatar → **Avatar**
- Stacked user avatars → **AvatarGroup** - Stacked user avatars → **AvatarGroup**
### "I need to group buttons or toggle selections"
- 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
@@ -106,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
@@ -148,37 +161,43 @@ 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 | 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 |
| DataTable | composite | Sortable, paginated data table with row actions | | ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
| DateRangePicker | primitive | Date range selection with presets | | 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 |
| FilterBar | composite | Search + filter controls for data views | | FilterBar | composite | Search + filter controls for data views |
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. | | GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
| FilterPill | primitive | Individual filter chip (active/inactive) | | 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) |
@@ -199,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

@@ -6,6 +6,12 @@
overflow: hidden; overflow: hidden;
} }
.flush {
border: none;
border-radius: 0;
box-shadow: none;
}
.scroll { .scroll {
overflow-x: auto; overflow-x: auto;
} }

View File

@@ -23,6 +23,7 @@ export function DataTable<T extends { id: string }>({
pageSizeOptions = [10, 25, 50, 100], pageSizeOptions = [10, 25, 50, 100],
rowAccent, rowAccent,
expandedContent, expandedContent,
flush = false,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null) const [sortKey, setSortKey] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc') const [sortDir, setSortDir] = useState<SortDir>('asc')
@@ -73,7 +74,7 @@ export function DataTable<T extends { id: string }>({
})) }))
return ( return (
<div className={styles.wrapper}> <div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
<div className={styles.scroll}> <div className={styles.scroll}>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>

View File

@@ -18,4 +18,6 @@ export interface DataTableProps<T extends { id: string }> {
pageSizeOptions?: number[] pageSizeOptions?: number[]
rowAccent?: (row: T) => 'error' | 'warning' | undefined rowAccent?: (row: T) => 'error' | 'warning' | undefined
expandedContent?: (row: T) => ReactNode | null expandedContent?: (row: T) => ReactNode | null
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
flush?: boolean
} }

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,7 +57,30 @@ 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,
})),
}))
}
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, starrable: true,
})), })),
})) }))
@@ -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'}
>
{routesCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/routes')}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
> >
<span className={styles.navIcon}></span> Routes
<div className={styles.itemInfo}> </span>
<div className={styles.itemName}>Metrics</div>
</div>
</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

@@ -22,7 +22,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Center search trigger */ /* Search trigger */
.search { .search {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -36,9 +36,9 @@
font-family: var(--font-body); font-family: var(--font-body);
cursor: pointer; cursor: pointer;
transition: border-color 0.15s; transition: border-color 0.15s;
min-width: 180px; width: 200px;
flex: 1; flex-shrink: 1;
max-width: 280px; min-width: 120px;
text-align: left; text-align: left;
} }
@@ -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,47 +18,34 @@ 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 ?? ''}`}>
{/* Left: Breadcrumb */} {/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} /> <Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
{/* Filters: time range + status pills */} {/* Search trigger */}
<div className={styles.filters}>
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{STATUS_PILLS.map(({ status, label }) => (
<FilterPill
key={status}
label={label}
active={globalFilters.statusFilters.has(status)}
onClick={() => globalFilters.toggleStatus(status)}
/>
))}
</div>
{/* Center: Search trigger */}
<button <button
className={styles.search} className={styles.search}
onClick={() => commandPalette.setOpen(true)} onClick={() => commandPalette.setOpen(true)}
@@ -72,16 +62,54 @@ export function TopBar({
<span className={styles.kbd}>Ctrl+K</span> <span className={styles.kbd}>Ctrl+K</span>
</button> </button>
{/* Right: env badge, user */} {/* Status filter group */}
<ButtonGroup
items={STATUS_ITEMS}
value={globalFilters.statusFilters}
onChange={(selected) => {
// Sync with global filter by toggling the diff
const current = globalFilters.statusFilters
for (const v of selected) {
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/>
{/* Time range pills */}
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{/* Right: 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 && (
<Dropdown
trigger={
<div className={styles.user}> <div className={styles.user}>
<span className={styles.userName}>{user.name}</span> <span className={styles.userName}>{user.name}</span>
<Avatar name={user.name} size="md" /> <Avatar name={user.name} size="md" />
</div> </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

@@ -0,0 +1,59 @@
.group {
display: inline-flex;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-surface);
}
.btn {
display: inline-flex;
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;
}
.btn:last-child {
border-right: none;
}
.btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn:focus-visible {
outline: 2px solid var(--amber);
outline-offset: -2px;
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

@@ -0,0 +1,60 @@
import { type ReactNode } from 'react'
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 {
items: ButtonGroupItem[]
/** Currently selected values (multi-select) */
value: Set<string>
onChange: (value: Set<string>) => void
className?: string
}
export function ButtonGroup({ items, value, onChange, className }: ButtonGroupProps) {
function handleClick(itemValue: string) {
const next = new Set(value)
if (next.has(itemValue)) {
next.delete(itemValue)
} else {
next.add(itemValue)
}
onChange(next)
}
return (
<div className={`${styles.group} ${className ?? ''}`} role="group">
{items.map((item) => {
const active = value.has(item.value)
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>
)
}

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')
return ( function getDaysInMonth(year: number, month: number): number {
date.getFullYear() + return new Date(year, month + 1, 0).getDate()
'-' +
pad(date.getMonth() + 1) +
'-' +
pad(date.getDate()) +
'T' +
pad(date.getHours()) +
':' +
pad(date.getMinutes())
)
} }
export const DateTimePicker = forwardRef<HTMLInputElement, DateTimePickerProps>( function getFirstDayOfWeek(year: number, month: number): number {
({ value, onChange, label, className, ...rest }, ref) => { const day = new Date(year, month, 1).getDay()
const inputValue = value ? toLocalDateTimeString(value) : '' return day === 0 ? 6 : day - 1 // Monday = 0
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { function formatDisplay(d: Date | undefined): string {
if (!onChange) return if (!d) return '—'
const v = e.target.value const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
onChange(v ? new Date(v) : null) 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 (
<div className={`${styles.wrapper} ${className ?? ''}`}> <div className={`${styles.wrapper} ${className ?? ''}`}>
{label && <label className={styles.label}>{label}</label>} {label && <span className={styles.label}>{label}</span>}
<button
ref={triggerRef}
type="button"
className={styles.trigger}
onClick={() => setOpen(!open)}
>
{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 <input
ref={ref} type="text"
type="datetime-local" className={styles.timeInput}
className={styles.input} value={hour}
value={inputValue} onChange={(e) => setHour(e.target.value.replace(/\D/g, '').slice(0, 2))}
onChange={handleChange} maxLength={2}
{...rest} 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> </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>
) )
}, }
)
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

@@ -1,3 +1,4 @@
import { forwardRef } from 'react'
import styles from './FilterPill.module.css' import styles from './FilterPill.module.css'
interface FilterPillProps { interface FilterPillProps {
@@ -6,31 +7,39 @@ interface FilterPillProps {
active?: boolean active?: boolean
dot?: boolean dot?: boolean
dotColor?: string dotColor?: string
activeColor?: string
onClick?: () => void onClick?: () => void
className?: string className?: string
} }
export function FilterPill({ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
({
label, label,
count, count,
active = false, active = false,
dot = false, dot = false,
dotColor, dotColor,
activeColor,
onClick, onClick,
className, className,
}: FilterPillProps) { }, 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 className={classes} onClick={onClick} type="button"> <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>
@@ -39,4 +48,7 @@ export function FilterPill({
)} )}
</button> </button>
) )
} },
)
FilterPill.displayName = 'FilterPill'

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,45 +1,11 @@
.trigger { .rangeRow {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
padding: 4px 10px; }
height: 28px;
border: 1px solid var(--border); .rangeSep {
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--amber, var(--warning));
font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
font-weight: 600; color: var(--text-faint);
cursor: pointer; flex-shrink: 0;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.trigger:hover {
border-color: var(--text-faint);
background: var(--bg-surface);
}
.icon {
font-size: 13px;
line-height: 1;
}
.label {
line-height: 1;
}
.caret {
font-size: 9px;
opacity: 0.7;
line-height: 1;
}
.presetList {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
min-width: 100px;
} }

View File

@@ -1,19 +1,21 @@
import { useState, useEffect } from 'react'
import styles from './TimeRangeDropdown.module.css' import styles from './TimeRangeDropdown.module.css'
import { Popover } from '../../composites/Popover/Popover' import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs'
import { FilterPill } from '../FilterPill/FilterPill' import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { computePresetRange, PRESET_SHORT_LABELS } from '../../utils/timePresets' import { computePresetRange } from '../../utils/timePresets'
import type { TimeRange } from '../../providers/GlobalFilterProvider' import type { TimeRange } from '../../providers/GlobalFilterProvider'
const DROPDOWN_PRESETS = [ const PRESETS = [
{ value: 'last-1h', label: '1h' }, { value: 'last-1h', label: '1h' },
{ value: 'last-3h', label: '3h' }, { value: 'last-3h', label: '3h' },
{ value: 'last-6h', label: '6h' }, { value: 'last-6h', label: '6h' },
{ value: 'today', label: 'Today' }, { value: 'today', label: 'Today' },
{ value: 'shift', label: 'Shift' },
{ value: 'last-24h', label: '24h' }, { value: 'last-24h', label: '24h' },
{ 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
@@ -21,35 +23,78 @@ interface TimeRangeDropdownProps {
} }
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) { export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
const activeLabel = value.preset ? (PRESET_SHORT_LABELS[value.preset] ?? value.preset) : 'Custom' const [customFrom, setCustomFrom] = useState<Date>(value.start)
const [customTo, setCustomTo] = useState<Date>(value.end)
const [toIsSet, setToIsSet] = useState(false)
const isCustom = value.preset === null || value.preset === 'custom'
const activeValue = isCustom ? CUSTOM_VALUE : (value.preset ?? 'last-1h')
// Sync local state when value changes from presets
useEffect(() => {
setCustomFrom(value.start)
setCustomTo(value.end)
}, [value.start, value.end])
function handleTabChange(tabValue: string) {
if (tabValue === CUSTOM_VALUE) return
setToIsSet(false)
const range = computePresetRange(tabValue)
onChange({ ...range, preset: tabValue })
}
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 (
<Popover <div className={className}>
className={className} <SegmentedTabs
position="bottom" tabs={PRESETS}
align="start" active={activeValue}
trigger={ onChange={handleTabChange}
<button className={styles.trigger} type="button" aria-label="Select time range"> trailing={rangeContent}
<span className={styles.icon} aria-hidden="true">&#9201;</span> trailingValue={CUSTOM_VALUE}
<span className={styles.label}>{activeLabel}</span>
<span className={styles.caret} aria-hidden="true">&#9662;</span>
</button>
}
content={
<div className={styles.presetList}>
{DROPDOWN_PRESETS.map((preset) => (
<FilterPill
key={preset.value}
label={preset.label}
active={value.preset === preset.value}
onClick={() => {
const range = computePresetRange(preset.value)
onChange({ ...range, preset: preset.value })
}}
/> />
))}
</div> </div>
}
/>
) )
} }

View File

@@ -2,6 +2,8 @@ export { Alert } from './Alert/Alert'
export { Avatar } from './Avatar/Avatar' 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 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'
@@ -12,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

@@ -20,7 +20,7 @@ interface GlobalFilterContextValue {
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null) const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
const DEFAULT_PRESET = 'last-3h' const DEFAULT_PRESET = 'last-1h'
function getDefaultTimeRange(): TimeRange { function getDefaultTimeRange(): TimeRange {
const { start, end } = computePresetRange(DEFAULT_PRESET) const { start, end } = computePresetRange(DEFAULT_PRESET)

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

@@ -0,0 +1,14 @@
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.css' {
const css: string
export default css
}
declare module '*.svg' {
const url: string
export default url
}

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>
<ToastProvider>
<App /> <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}>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link> {scope.level !== 'all' && (
{scope.level === 'instance' && (
<> <>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeSep}>&#9656;</span>
<Link to={`/agents/${scope.appId}`} className={styles.scopeLink}>{scope.appId}</Link> <span className={styles.scopeCurrent}>{scope.appId}</span>
</> </>
)} )}
<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,14 +376,13 @@ 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,
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '', selectedInstance?.id === inst.id && panelOpen ? styles.instanceRowActive : '',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
onClick={() => navigate(`/agents/${inst.appId}/${inst.id}`)} onClick={() => handleInstanceClick(inst)}
> >
<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'} />
@@ -283,35 +418,6 @@ export function AgentHealth() {
</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,14 +263,102 @@ 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={
routeId
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
: appId
? [{ label: 'Applications', href: '/apps' }, { label: appId }] ? [{ label: 'Applications', href: '/apps' }, { label: appId }]
: [{ label: 'Applications' }] : [{ label: 'Applications' }]
} }
@@ -346,6 +403,7 @@ export function Dashboard() {
onRowClick={handleRowClick} onRowClick={handleRowClick}
selectedId={selectedId} selectedId={selectedId}
sortable sortable
flush
rowAccent={handleRowAccent} rowAccent={handleRowAccent}
expandedContent={(row) => expandedContent={(row) =>
row.errorMessage ? ( row.errorMessage ? (

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 */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Processor Timeline</span>
<span className={styles.sectionMeta}>Total: {formatDuration(exchange.durationMs)}</span>
</div>
<div className={styles.timelineWrap}>
<ProcessorTimeline
processors={exchange.processors}
totalMs={exchange.durationMs}
/>
</div>
</div>
{/* Step-by-step inspector */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Exchange Inspector</span>
<span className={styles.sectionMeta}>{exchange.processors.length} processor steps</span>
</div>
<div className={styles.inspectorSteps}>
{exchange.processors.map((proc, index) => {
const snapshot = generateExchangeSnapshot(proc, exchange.orderId, exchange.customer, index)
const stepStatusClass =
proc.status === 'fail'
? styles.stepFail
: proc.status === 'slow'
? styles.stepSlow
: styles.stepOk
{/* Correlation Chain */}
{correlatedExchanges.length > 1 && (
<div className={styles.correlationChain}>
<span className={styles.chainLabel}>Correlated Exchanges</span>
{correlatedExchanges.map((ce) => {
const isCurrent = ce.id === exchange.id
const variant = statusToVariant(ce.status)
const statusCls =
variant === 'success' ? styles.chainNodeSuccess
: variant === 'error' ? styles.chainNodeError
: variant === 'running' ? styles.chainNodeRunning
: styles.chainNodeWarning
return ( return (
<Collapsible <button
key={index} key={ce.id}
title={ className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
<div className={styles.stepTitle}> onClick={() => {
<span className={`${styles.stepIndex} ${stepStatusClass}`}>{index + 1}</span> if (!isCurrent) navigate(`/exchanges/${ce.id}`)
<span className={styles.stepName}>{proc.name}</span> }}
<Badge title={`${ce.id}${ce.route}`}
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}> <StatusDot variant={variant} />
<div className={styles.stepPanel}> <span>{ce.route}</span>
<div className={styles.stepPanelLabel}>Exchange Headers</div> </button>
<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>
)}
</div> </div>
{/* Error block (if failed) */} {/* Processor Timeline Section */}
{exchange.status === 'failed' && exchange.errorMessage && ( <div className={styles.timelineSection}>
<div className={styles.errorSection}> <div className={styles.timelineHeader}>
<div className={styles.sectionHeader}> <span className={styles.timelineTitle}>
<span className={styles.sectionTitle}>Error Details</span> Processor Timeline
<Badge label="FAILED" color="error" /> <span className={styles.procCount}>{exchange.processors.length} processors</span>
</div> </span>
<div className={styles.errorBody}> <div className={styles.timelineToggle}>
<div className={styles.errorClass}>{exchange.errorClass}</div> <button
<pre className={styles.errorMessage}>{exchange.errorMessage}</pre> className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
<div className={styles.errorHint}> onClick={() => setTimelineView('gantt')}
Failed at processor: <MonoText size="xs"> >
{exchange.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'} Timeline
</MonoText> </button>
<button
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('flow')}
>
Flow
</button>
</div> </div>
</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>
{/* 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}>
{component.label}
</a> </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

@@ -328,6 +328,7 @@ export function RouteDetail() {
columns={EXCHANGE_COLUMNS} columns={EXCHANGE_COLUMNS}
data={routeExchanges} data={routeExchanges}
sortable sortable
flush
rowAccent={(row) => { rowAccent={(row) => {
if (row.status === 'failed') return 'error' if (row.status === 'failed') return 'error'
if (row.status === 'warning') return 'warning' if (row.status === 'warning') return 'warning'

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