Compare commits

...

48 Commits

Author SHA1 Message Date
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
hsiegeln
92ea8673fc docs: add consumer usage guide for @cameleer/design-system package
Some checks failed
Build & Publish / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:34:23 +01:00
hsiegeln
3be4c0a976 feat: configure package.json for @cameleer/design-system publishing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:32:49 +01:00
hsiegeln
d1e5499688 ci: add Gitea Actions workflow for npm publishing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:32:28 +01:00
hsiegeln
8da0363089 feat: add Vite library build config with dts generation
Separate vite.lib.config.ts for library mode builds:
- ES module output (index.es.js) with react/react-dom externalized
- Consolidated type declarations via rollupTypes (index.es.d.ts)
- CSS Modules with debuggable scoped names (cameleer_ prefix)
- Deterministic output filenames (style.css, index.es.js)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:30:45 +01:00
hsiegeln
5c1add8c9e feat: add library entry point for design system package
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:26:11 +01:00
hsiegeln
cebaa2c55c chore: add vite-plugin-dts and ignore dist/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:24:50 +01:00
hsiegeln
2c427a31a1 docs: add implementation plan for design system packaging
8-task plan covering: git remote, vite-plugin-dts, library entry point,
Vite lib config, package.json, Gitea Actions CI/CD, consumer docs, push.

Fixes from review: deterministic output filename, .npmrc echo instead of
heredoc, dist/ in .gitignore, prerequisite for untracked files, dts
verification, worktree guidance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:19:16 +01:00
hsiegeln
727a5de9dc docs: fix spec issues from review
- Add tokens.css/reset.css imports to entry point (critical: CSS tokens would be missing)
- Add font loading docs for consumers (DM Sans, JetBrains Mono)
- Add publishConfig for Gitea registry (npm publish would target npmjs.org)
- Fix exports map: types condition first
- Add scoped registry line to CI .npmrc
- Note vite-plugin-dts as required devDependency
- Add repository field to package.json spec
- Note tsconfig.node.json update needed
- CSS Module scoping strategy for debuggable class names
- Note CI auth requirements for consuming apps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:12:28 +01:00
hsiegeln
45c35b59fe docs: add design spec for design system packaging
Covers Vite library mode build, Gitea npm registry publishing,
snapshot + tag-based versioning, and consumer setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:07:28 +01:00
hsiegeln
5de97dab14 feat: unified global search & filter system with Cmd-K navigation
Replace per-page filtering with a single GlobalFilterProvider (time range +
status) consumed by a redesigned TopBar across all pages. Lift CommandPalette
to App level so Cmd-K works globally with filtered results that navigate to
exchanges, routes, agents, and applications. Sidebar auto-reveals and selects
the target entry on Cmd-K navigation via location state.

- Extract shared time preset utilities (computePresetRange, DEFAULT_PRESETS)
- Add GlobalFilterProvider (time range + status) and CommandPaletteProvider
- Add TimeRangeDropdown primitive with Popover preset list
- Redesign TopBar: breadcrumb | time dropdown | status pills | search | env
- Add application category to Cmd-K search
- Remove FilterBar and local DateRangePicker from Dashboard/Metrics pages
- Filter AgentHealth EventFeed by global time range
- Remove shift/onSearchClick props from TopBar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:06:25 +01:00
103 changed files with 14781 additions and 1413 deletions

View File

@@ -0,0 +1,43 @@
name: Build & Publish
on:
push:
branches: [main]
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
container:
image: node:22-bookworm-slim
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npx vitest run
- name: Build library
run: npm run build:lib
- name: Publish package
shell: bash
run: |
case "$GITHUB_REF" in
refs/tags/v*)
VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version
TAG="latest"
;;
*)
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
DATE=$(date +%Y%m%d)
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
TAG="dev"
;;
esac
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
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`
@@ -42,3 +42,68 @@ import type { Column } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell' import { AppShell } from '../design-system/layout/AppShell'
import { ThemeProvider } from '../design-system/providers/ThemeProvider' import { ThemeProvider } from '../design-system/providers/ThemeProvider'
``` ```
## Using This Design System in Other Apps
This design system is published as `@cameleer/design-system` to the Gitea npm registry.
### Registry: `https://gitea.siegeln.net/api/packages/cameleer/npm/`
### Setup in a consuming app
1. Add `.npmrc` to the project root:
```
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
```
Note: CI pipelines for consuming apps also need this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
2. Install:
```bash
# Snapshot builds (during development)
npm install @cameleer/design-system@dev
# Stable releases
npm install @cameleer/design-system
```
3. Add fonts to `index.html` (required — the package does not bundle fonts):
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
```
Without these, `var(--font-body)` and `var(--font-mono)` fall back to `system-ui` / `monospace`.
4. Import styles once at app root, then use components:
```tsx
import '@cameleer/design-system/style.css'
import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
```
### Import Paths (Consumer)
```tsx
// All components from single entry
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
// Types
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
// Providers
import { ThemeProvider, useTheme } from '@cameleer/design-system'
import { CommandPaletteProvider, useCommandPalette } from '@cameleer/design-system'
import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
// Utils
import { hashColor } from '@cameleer/design-system'
// Styles (once, at app root)
import '@cameleer/design-system/style.css'
```

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,28 +218,31 @@ 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
### Within this repo (design system development)
```tsx ```tsx
// Primitives import { Button, Input, Badge } from './design-system/primitives'
import { Button, Input, Badge, ... } from './design-system/primitives' import { DataTable, Modal, Toast } from './design-system/composites'
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
// Composites
import { DataTable, Modal, Toast, ... } from './design-system/composites'
import type { Column, SearchResult, FeedEvent, ... } from './design-system/composites'
// Layout
import { AppShell } from './design-system/layout/AppShell' import { AppShell } from './design-system/layout/AppShell'
import { Sidebar } from './design-system/layout/Sidebar'
import { TopBar } from './design-system/layout/TopBar'
// Theme
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider' import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
``` ```
### From consuming apps (via npm package)
```tsx
import '@cameleer/design-system/style.css' // once at app root
import { Button, Input, Modal, DataTable, AppShell, ThemeProvider } from '@cameleer/design-system'
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
```
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
## Styling Rules ## Styling Rules
- **CSS Modules only** — no inline styles except dynamic values (width, color from props) - **CSS Modules only** — no inline styles except dynamic values (width, color from props)

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,612 @@
# Design System Packaging 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.
>
> **IMPORTANT: Use an isolated git worktree** — another agent may be working on the main tree. Create a worktree before starting any task. The worktree must be created from the current working state (not a clean `main`) because several provider/util files are uncommitted.
**Goal:** Package the Cameleer3 design system as `@cameleer/design-system` and publish it to Gitea's npm registry via CI/CD.
**Architecture:** Vite library mode builds the design system into an ESM bundle + CSS + TypeScript declarations. A Gitea Actions workflow publishes snapshot versions on every push to main, and stable versions on `v*` tags. Consuming apps install from Gitea's npm registry.
**Tech Stack:** Vite (library mode), vite-plugin-dts, TypeScript, CSS Modules, Gitea Actions
**Spec:** `docs/superpowers/specs/2026-03-18-design-system-packaging-design.md`
---
## Prerequisites
Before starting, ensure these currently-untracked files are committed (they are referenced by the library entry point):
- `src/design-system/providers/CommandPaletteProvider.tsx`
- `src/design-system/providers/GlobalFilterProvider.tsx`
- `src/design-system/utils/timePresets.ts`
The git remote should be added **before** creating a worktree, since remotes are repo-level config shared across all worktrees.
---
## File Map
| Action | File | Responsibility |
|--------|------|----------------|
| Create | `src/design-system/index.ts` | Library entry point — re-exports all components, imports global CSS |
| Create | `vite.lib.config.ts` | Vite library build config (separate from app build) |
| Create | `.gitea/workflows/publish.yml` | CI/CD: test, build, publish on push to main / tag |
| Modify | `.gitignore` | Add `dist/` to prevent build artifacts from being committed |
| Modify | `package.json` | Rename, add exports/peers/publishConfig/files/repository |
| Modify | `tsconfig.node.json` | Include `vite.lib.config.ts` |
| Modify | `CLAUDE.md` | Add consumer usage docs for AI agents |
| Modify | `COMPONENT_GUIDE.md` | Add package import paths for consumers |
---
### Task 1: Add git remote and commit untracked files
**Files:**
- None created (git operations + staging existing untracked files)
- [ ] **Step 1: Add the origin remote**
```bash
git remote add origin https://gitea.siegeln.net/cameleer/design-system.git
```
If `origin` already exists, use `git remote set-url origin https://gitea.siegeln.net/cameleer/design-system.git` instead.
- [ ] **Step 2: Verify remote**
```bash
git remote -v
```
Expected: `origin https://gitea.siegeln.net/cameleer/design-system.git (fetch)` and `(push)`
- [ ] **Step 3: Commit untracked provider/util files**
These files are required by the library entry point (Task 3) but are currently untracked:
```bash
git add src/design-system/providers/CommandPaletteProvider.tsx src/design-system/providers/GlobalFilterProvider.tsx src/design-system/utils/timePresets.ts
git commit -m "feat: add CommandPaletteProvider, GlobalFilterProvider, and timePresets"
```
- [ ] **Step 4: Commit any other pending changes**
Check `git status` — there may be other modified files from a prior agent's work. Stage and commit anything that belongs on main before proceeding:
```bash
git status
```
If there are modified files, commit them with an appropriate message before continuing.
---
### Task 2: Install vite-plugin-dts and add dist/ to .gitignore
**Files:**
- Modify: `package.json` (devDependencies)
- Modify: `.gitignore`
- [ ] **Step 1: Install the plugin**
```bash
npm install -D vite-plugin-dts
```
Note: `vite-plugin-dts` with `rollupTypes: true` requires `@microsoft/api-extractor` as a peer dependency. If the install warns about this, also install it:
```bash
npm install -D @microsoft/api-extractor
```
- [ ] **Step 2: Verify it's in devDependencies**
```bash
node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).devDependencies['vite-plugin-dts'])"
```
Expected: a version string like `^4.x.x`
- [ ] **Step 3: Add `dist/` to `.gitignore`**
Append to `.gitignore`:
```
dist/
```
This prevents build artifacts from being accidentally committed.
- [ ] **Step 4: Commit**
```bash
git add package.json package-lock.json .gitignore
git commit -m "chore: add vite-plugin-dts and ignore dist/"
```
---
### Task 3: Create library entry point
**Files:**
- Create: `src/design-system/index.ts`
- [ ] **Step 1: Create `src/design-system/index.ts`**
```ts
import './tokens.css'
import './reset.css'
export * from './primitives'
export * from './composites'
export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'
```
This file imports `tokens.css` and `reset.css` at the top so Vite includes them in the bundled `style.css`. Without these imports, all `var(--*)` tokens would resolve to nothing in consuming apps.
- [ ] **Step 2: Verify TypeScript is happy**
```bash
npx tsc -b --noEmit
```
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/design-system/index.ts
git commit -m "feat: add library entry point for design system package"
```
---
### Task 4: Create Vite library build config
**Files:**
- Create: `vite.lib.config.ts`
- Modify: `tsconfig.node.json`
- [ ] **Step 1: Create `vite.lib.config.ts`**
Note: `__dirname` works in Vite config files despite this being an ESM project — Vite transpiles config files before executing them.
```ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
react(),
dts({
include: ['src/design-system'],
outDir: 'dist',
rollupTypes: true,
}),
],
css: {
modules: {
localsConvention: 'camelCase',
generateScopedName: 'cameleer_[name]_[local]_[hash:5]',
},
},
build: {
lib: {
entry: resolve(__dirname, 'src/design-system/index.ts'),
formats: ['es'],
fileName: () => 'index.es.js',
},
rollupOptions: {
external: ['react', 'react-dom', 'react-router-dom', 'react/jsx-runtime'],
},
cssFileName: 'style',
},
})
```
Key choices:
- `rollupTypes: true` consolidates all `.d.ts` into a single `dist/index.d.ts`
- `generateScopedName: 'cameleer_[name]_[local]_[hash:5]'` makes class names debuggable in consumer devtools
- `react/jsx-runtime` is externalized (peer dep of React, not bundled)
- `cssFileName: 'style'` ensures output is `dist/style.css`
- `fileName: () => 'index.es.js'` forces a deterministic output filename — Vite 6 defaults to `.mjs` for ES format which would mismatch `package.json` exports
- [ ] **Step 2: Update `tsconfig.node.json` to include the new config file**
Change the `include` array from:
```json
"include": ["vite.config.ts"]
```
to:
```json
"include": ["vite.config.ts", "vite.lib.config.ts"]
```
- [ ] **Step 3: Test the library build**
The `build:lib` script isn't in `package.json` yet (added in Task 5). Run directly:
```bash
npx vite build --config vite.lib.config.ts
```
Expected: `dist/` directory created with `index.es.js`, `style.css`, and `index.d.ts`
- [ ] **Step 4: Verify the output**
```bash
ls dist/
```
Expected: `index.es.js`, `style.css`, `index.d.ts`
Check that `style.css` contains token variables:
```bash
grep "bg-body" dist/style.css
```
Expected: matches found (proves tokens.css was included)
Check that type declarations contain exported components:
```bash
grep "Button" dist/index.d.ts
```
Expected: matches found (proves types were generated)
If `index.d.ts` is missing or empty, `rollupTypes` may have failed silently. In that case, install `@microsoft/api-extractor` and rebuild, or set `rollupTypes: false` (which produces individual `.d.ts` files — less clean but functional).
- [ ] **Step 5: Clean up and commit**
```bash
rm -rf dist
git add vite.lib.config.ts tsconfig.node.json
git commit -m "feat: add Vite library build config with dts generation"
```
---
### Task 5: Update package.json for library publishing
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Update package.json**
Apply these changes to the existing `package.json`:
1. Change `"name"` from `"cameleer3"` to `"@cameleer/design-system"`
2. Change `"version"` from `"0.0.0"` to `"0.1.0"`
3. Remove `"private": true`
4. Add `"main": "./dist/index.es.js"`
5. Add `"module": "./dist/index.es.js"`
6. Add `"types": "./dist/index.d.ts"`
7. Add `"exports"` block (note: `types` must come first):
```json
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
}
```
8. Add `"files": ["dist"]`
9. Add `"sideEffects": ["*.css"]`
10. Add `"publishConfig"`:
```json
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
}
```
11. Add `"repository"`:
```json
"repository": {
"type": "git",
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
}
```
12. Add `"peerDependencies"`:
```json
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
}
```
13. Add to `"scripts"`:
```json
"build:lib": "vite build --config vite.lib.config.ts"
```
The final `package.json` should look like:
```json
{
"name": "@cameleer/design-system",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
},
"repository": {
"type": "git",
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:lib": "vite build --config vite.lib.config.ts",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"happy-dom": "^20.8.4",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vite-plugin-dts": "<version installed in Task 2>",
"vitest": "^3.0.0"
}
}
```
- [ ] **Step 2: Verify the full library build works end-to-end**
```bash
npm run build:lib
```
Expected: succeeds, `dist/` contains `index.es.js`, `style.css`, `index.d.ts`
- [ ] **Step 3: Clean up and commit**
```bash
rm -rf dist
git add package.json
git commit -m "feat: configure package.json for @cameleer/design-system publishing"
```
---
### Task 6: Create Gitea Actions workflow
**Files:**
- Create: `.gitea/workflows/publish.yml`
- [ ] **Step 1: Create `.gitea/workflows/publish.yml`**
```yaml
name: Build & Publish
on:
push:
branches: [main]
tags: ['v*']
jobs:
publish:
runs-on: linux-arm64
container:
image: node:22-bookworm-slim
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npx vitest run
- name: Build library
run: npm run build:lib
- name: Publish package
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version
TAG="latest"
else
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
DATE=$(date +%Y%m%d)
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
TAG="dev"
fi
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
npm publish --tag "$TAG"
```
Note: The `.npmrc` is written with `echo` commands (not a heredoc) to avoid YAML indentation being included in the file content, which would break npm's parsing.
- [ ] **Step 2: Commit**
```bash
mkdir -p .gitea/workflows
git add .gitea/workflows/publish.yml
git commit -m "ci: add Gitea Actions workflow for npm publishing"
```
---
### Task 7: Update documentation for consumers
**Files:**
- Modify: `CLAUDE.md`
- Modify: `COMPONENT_GUIDE.md`
- [ ] **Step 1: Add consumer section to `CLAUDE.md`**
Add the following section at the end of `CLAUDE.md`:
```markdown
## Using This Design System in Other Apps
This design system is published as `@cameleer/design-system` to the Gitea npm registry.
### Registry: `https://gitea.siegeln.net/api/packages/cameleer/npm/`
### Setup in a consuming app
1. Add `.npmrc` to the project root:
```
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
```
Note: CI pipelines for consuming apps also need this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
2. Install:
```bash
# Snapshot builds (during development)
npm install @cameleer/design-system@dev
# Stable releases
npm install @cameleer/design-system
```
3. Add fonts to `index.html` (required — the package does not bundle fonts):
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
```
Without these, `var(--font-body)` and `var(--font-mono)` fall back to `system-ui` / `monospace`.
4. Import styles once at app root, then use components:
```tsx
import '@cameleer/design-system/style.css'
import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
```
### Import Paths (Consumer)
```tsx
// All components from single entry
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
// Types
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
// Providers
import { ThemeProvider, useTheme } from '@cameleer/design-system'
import { CommandPaletteProvider, useCommandPalette } from '@cameleer/design-system'
import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
// Utils
import { hashColor } from '@cameleer/design-system'
// Styles (once, at app root)
import '@cameleer/design-system/style.css'
```
```
- [ ] **Step 2: Update the `## Import Paths` section in `COMPONENT_GUIDE.md`**
Find the `## Import Paths` section heading and replace everything from that heading down to the next `##` heading (or end of file) with:
```markdown
## Import Paths
### Within this repo (design system development)
```tsx
import { Button, Input, Badge } from './design-system/primitives'
import { DataTable, Modal, Toast } from './design-system/composites'
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
import { AppShell } from './design-system/layout/AppShell'
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
```
### From consuming apps (via npm package)
```tsx
import '@cameleer/design-system/style.css' // once at app root
import { Button, Input, Modal, DataTable, AppShell, ThemeProvider } from '@cameleer/design-system'
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
```
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
```
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md COMPONENT_GUIDE.md
git commit -m "docs: add consumer usage guide for @cameleer/design-system package"
```
---
### Task 8: Push to Gitea and verify CI
**Files:**
- None (git operations only)
- [ ] **Step 1: Push all commits to Gitea**
```bash
git push -u origin main
```
- [ ] **Step 2: Check CI status**
Use the Gitea MCP server or visit `https://gitea.siegeln.net/cameleer/design-system/actions` to monitor the workflow run. The workflow should:
1. Install deps
2. Run tests
3. Build the library
4. Publish a snapshot version with the `dev` tag
- [ ] **Step 3: Verify the package is published**
Check the Gitea package registry at `https://gitea.siegeln.net/cameleer/-/packages`. The `@cameleer/design-system` package should appear with a `0.0.0-snapshot.*` version tagged as `dev`.
If the workflow fails, check the job logs via the Gitea MCP server's `actions_run_read` tool for diagnostics.

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

@@ -0,0 +1,229 @@
# Design System Packaging — Design Spec
**Date:** 2026-03-18
**Status:** Approved
**Package:** `@cameleer/design-system`
**Registry:** Gitea npm registry at `gitea.siegeln.net`
**Repository:** `https://gitea.siegeln.net/cameleer/design-system`
## Goal
Package the Cameleer3 design system as a reusable npm library so other React applications in the Cameleer ecosystem can consume it via `npm install`. Publishing is automated via Gitea Actions.
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Registry | Gitea built-in npm registry | Already have Gitea infrastructure |
| Package scope | `@cameleer/design-system` | Matches the org |
| Export style | Single package, flat exports | Simple DX, tree-shaking handles unused code |
| What's included | Everything (primitives, composites, layout, providers, utils, tokens) | All consuming apps are Cameleer apps |
| Build tool | Vite library mode | Already using Vite, CSS Modules first-class |
| Output format | ESM only | All consumers are Vite/ESM |
| Versioning | Tag-based releases + snapshot on every main push | Snapshots for dev, tags for milestones |
| Runner arch | ARM64 | Gitea runner is ARM64 |
| Auth secret | `REGISTRY_TOKEN` (org-level) | Existing all-access token |
## 1. Library Entry Point
New file `src/design-system/index.ts` — the single public API. It must import global CSS at the top so that `tokens.css` and `reset.css` are included in the bundled `dist/style.css`:
```ts
import './tokens.css'
import './reset.css'
export * from './primitives'
export * from './composites'
export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'
```
Without the CSS imports, all `var(--*)` tokens used in component CSS Modules would resolve to nothing in consuming apps.
Consumers import the bundled CSS once at their app root:
```ts
import '@cameleer/design-system/style.css'
```
## 2. Vite Library Build Config
A separate `vite.lib.config.ts` to keep library and app builds independent:
- **Entry:** `src/design-system/index.ts`
- **Output:** `dist/index.es.js` (ESM)
- **CSS:** Extracted to `dist/style.css`
- **CSS Modules scoping:** `cameleer_[name]_[local]_[hash:5]` — debuggable in consumer devtools, unique enough to avoid collisions
- **Externals:** `react`, `react-dom`, `react-router-dom` (peer deps, not bundled)
- **Types:** `vite-plugin-dts` generates `dist/index.d.ts` with full TypeScript declarations
- **Build script:** `"build:lib": "vite build --config vite.lib.config.ts"`
- **New devDependency:** `vite-plugin-dts` must be installed
`tsconfig.node.json` must be updated to include `vite.lib.config.ts`.
Output structure:
```
dist/
index.es.js # ESM bundle
style.css # All CSS (tokens + reset + component modules)
index.d.ts # TypeScript declarations
```
## 3. Package Configuration
Updates to `package.json`:
```json
{
"name": "@cameleer/design-system",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
},
"repository": {
"type": "git",
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
}
}
```
- `"private": true` is removed
- `"types"` comes first in the exports conditions (TypeScript resolution requirement)
- `publishConfig` ensures `npm publish` targets the Gitea registry, not npmjs.org
- Existing `scripts`, `dependencies`, and `devDependencies` remain for the app build
- `peerDependencies` tells consumers what to provide
## 4. Gitea Actions CI/CD Pipeline
Workflow at `.gitea/workflows/publish.yml`:
**Triggers:**
- Push to `main` → publish snapshot (`0.0.0-snapshot.<YYYYMMDD>.<short-sha>`) with `dev` dist-tag
- Push tag `v*` → publish stable release (e.g., `1.0.0`) with `latest` dist-tag
**Steps:**
1. Checkout at ref
2. `npm ci` (install deps)
3. `npx vitest run` (gate: don't publish broken code)
4. `npm run build:lib` (build the library)
5. Determine version from tag or generate snapshot version
6. Configure `.npmrc` with scoped registry + auth token
7. `npm publish --tag <dev|latest>`
**Runner:** ARM64 with `node:22-bookworm-slim` container image.
```yaml
name: Build & Publish
on:
push:
branches: [main]
tags: ['v*']
jobs:
publish:
runs-on: linux-arm64
container:
image: node:22-bookworm-slim
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx vitest run
- run: npm run build:lib
- run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
npm version "$VERSION" --no-git-tag-version
TAG="latest"
else
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
DATE=$(date +%Y%m%d)
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
TAG="dev"
fi
cat > .npmrc << 'NPMRC'
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}
NPMRC
npm publish --tag "$TAG"
```
## 5. Consumer Setup
In any consuming app (e.g., `cameleer3-server/ui`):
**1. Add `.npmrc` to project root:**
```
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
```
Note: The consuming app's CI pipeline also needs this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
**2. Install:**
```bash
# During development (snapshot builds)
npm install @cameleer/design-system@dev
# For stable releases (later)
npm install @cameleer/design-system
```
**3. Add fonts to `index.html`:**
The design system uses DM Sans and JetBrains Mono via Google Fonts. These must be loaded by the consuming app since font `<link>` tags are not part of the library output:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
```
Without these, `var(--font-body)` and `var(--font-mono)` will fall back to `system-ui` / `monospace`.
**4. Use:**
```tsx
// Import styles once at app root
import '@cameleer/design-system/style.css'
// Import components
import { Button, Input, Modal, AppShell, ThemeProvider } from '@cameleer/design-system'
```
## 6. Documentation Updates
Update `CLAUDE.md` and `COMPONENT_GUIDE.md` in this repo with:
- The package name and registry URL
- How consuming apps should configure `.npmrc` (including CI)
- Font loading requirement (Google Fonts link)
- Import patterns for consumers (`@cameleer/design-system` instead of relative paths)
- Note that `style.css` must be imported once at the app root
This ensures other AI agents working on consuming Cameleer apps understand how to use the design system.

967
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,30 @@
{ {
"name": "cameleer3", "name": "@cameleer/design-system",
"private": true, "version": "0.1.0",
"version": "0.0.0",
"type": "module", "type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"types": "./dist/index.es.d.ts",
"exports": {
".": {
"types": "./dist/index.es.d.ts",
"import": "./dist/index.es.js"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
},
"repository": {
"type": "git",
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:lib": "vite build --config vite.lib.config.ts",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest" "test": "vitest"
@@ -15,6 +34,11 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"
}, },
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -25,6 +49,7 @@
"happy-dom": "^20.8.4", "happy-dom": "^20.8.4",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vite-plugin-dts": "^4.5.4",
"vitest": "^3.0.0" "vitest": "^3.0.0"
} }
} }

View File

@@ -1,26 +1,107 @@
import { Routes, Route, Navigate } from 'react-router-dom' import { useMemo, useCallback } from 'react'
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 type { SearchResult } from './design-system/composites/CommandPalette/types'
import { useCommandPalette } from './design-system/providers/CommandPaletteProvider'
import { useGlobalFilters } from './design-system/providers/GlobalFilterProvider'
import { buildSearchData } from './mocks/searchData'
import { exchanges } from './mocks/exchanges'
import { routes } from './mocks/routes'
import { agents } from './mocks/agents'
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
const routeToApp = buildRouteToAppMap()
/** Compute which sidebar path to reveal for a given search result */
function computeSidebarRevealPath(result: SearchResult): string | undefined {
if (!result.path) return undefined
if (result.category === 'application') {
return result.path
}
if (result.category === 'route') {
return result.path
}
if (result.category === 'agent') {
return result.path
}
if (result.category === 'exchange') {
const exchange = exchanges.find((e) => e.id === result.id)
if (exchange) {
const appId = routeToApp.get(exchange.route)
if (appId) return `/apps/${appId}/${exchange.route}`
}
}
return result.path
}
export default function App() { export default function App() {
const navigate = useNavigate()
const { open: paletteOpen, setOpen } = useCommandPalette()
const { isInTimeRange, statusFilters } = useGlobalFilters()
const filteredSearchData = useMemo(() => {
// Filter exchanges by time range and status
let filteredExchanges = exchanges.filter((e) => isInTimeRange(e.timestamp))
if (statusFilters.size > 0) {
filteredExchanges = filteredExchanges.filter((e) => statusFilters.has(e.status))
}
return buildSearchData(filteredExchanges, routes, agents)
}, [isInTimeRange, statusFilters])
const handleSelect = useCallback(
(result: SearchResult) => {
if (result.path) {
const sidebarReveal = computeSidebarRevealPath(result)
navigate(result.path, { state: sidebarReveal ? { sidebarReveal } : undefined })
}
setOpen(false)
},
[navigate, setOpen],
)
return ( return (
<Routes> <>
<Route path="/" element={<Navigate to="/apps" replace />} /> <Routes>
<Route path="/apps" element={<Dashboard />} /> <Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps/:id" element={<Dashboard />} /> <Route path="/apps" element={<Dashboard />} />
<Route path="/metrics" element={<Metrics />} /> <Route path="/apps/:id" element={<Dashboard />} />
<Route path="/routes/:id" element={<RouteDetail />} /> <Route path="/apps/:id/:routeId" element={<Dashboard />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} /> <Route path="/routes" element={<RoutesPage />} />
<Route path="/agents/*" element={<AgentHealth />} /> <Route path="/routes/:appId" element={<RoutesPage />} />
<Route path="/admin" element={<Admin />} /> <Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
<Route path="/api-docs" element={<ApiDocs />} /> <Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/inventory" element={<Inventory />} /> <Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
</Routes> <Route path="/agents/*" element={<AgentHealth />} />
<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="/inventory" element={<Inventory />} />
</Routes>
<CommandPalette
open={paletteOpen}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
data={filteredSearchData}
onSelect={handleSelect}
/>
</>
) )
} }

View File

@@ -16,6 +16,7 @@ interface CommandPaletteProps {
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = { const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
all: 'All', all: 'All',
application: 'Applications',
exchange: 'Exchanges', exchange: 'Exchanges',
route: 'Routes', route: 'Routes',
agent: 'Agents', agent: 'Agents',
@@ -23,6 +24,7 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all', 'all',
'application',
'exchange', 'exchange',
'route', 'route',
'agent', 'agent',

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export type SearchCategory = 'exchange' | 'route' | 'agent' export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
export interface SearchResult { export interface SearchResult {
id: string id: string
@@ -10,6 +10,7 @@ export interface SearchResult {
meta: string meta: string
timestamp?: string timestamp?: string
icon?: ReactNode icon?: ReactNode
path?: string
expandedContent?: string expandedContent?: string
matchRanges?: [number, number][] matchRanges?: [number, number][]
} }

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

@@ -0,0 +1,11 @@
import './tokens.css'
import './reset.css'
export * from './primitives'
export * from './composites'
export * from './layout'
export * from './providers/ThemeProvider'
export * from './providers/CommandPaletteProvider'
export * from './providers/GlobalFilterProvider'
export * from './utils/hashColor'
export * from './utils/timePresets'

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

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg' import camelLogoUrl from '../../../assets/camel-logo.svg'
@@ -57,12 +57,35 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
label: route.name, label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>, icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount), badge: formatCount(route.exchangeCount),
path: `/routes/${route.id}`, path: `/apps/${app.id}/${route.id}`,
starrable: true, starrable: true,
})), })),
})) }))
} }
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.routes.length > 0)
.map((app) => ({
id: `routes:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: `${app.routes.length} routes`,
path: `/routes/${app.id}`,
starrable: true,
starKey: `routes:${app.id}`,
children: app.routes.map((route) => ({
id: `routestat:${app.id}:${route.id}`,
starKey: `routes:${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
starrable: true,
})),
}))
}
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps return apps
.filter((app) => app.agents.length > 0) .filter((app) => app.agents.length > 0)
@@ -95,7 +118,7 @@ interface StarredItem {
label: string label: string
icon?: React.ReactNode icon?: React.ReactNode
path: string path: string
type: 'application' | 'route' | 'agent' type: 'application' | 'route' | 'agent' | 'routestat'
parentApp?: string parentApp?: string
} }
@@ -118,24 +141,57 @@ function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): Starr
items.push({ items.push({
starKey: key, starKey: key,
label: route.name, label: route.name,
path: `/routes/${route.id}`, path: `/apps/${app.id}/${route.id}`,
type: 'route', type: 'route',
parentApp: app.name, parentApp: app.name,
}) })
} }
} }
const agentsAppKey = `agents:${app.id}`
if (starredIds.has(agentsAppKey)) {
items.push({
starKey: agentsAppKey,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/agents/${app.id}`,
type: 'agent',
})
}
for (const agent of app.agents) { for (const agent of app.agents) {
const key = `${app.id}:${agent.id}` const key = `${app.id}:${agent.id}`
if (starredIds.has(key)) { if (starredIds.has(key)) {
items.push({ items.push({
starKey: key, starKey: key,
label: agent.name, label: agent.name,
path: `/agents/${agent.id}`, path: `/agents/${app.id}/${agent.id}`,
type: 'agent', type: 'agent',
parentApp: app.name, parentApp: app.name,
}) })
} }
} }
// Routes tree starred items
const routesAppKey = `routes:${app.id}`
if (starredIds.has(routesAppKey)) {
items.push({
starKey: routesAppKey,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/routes/${app.id}`,
type: 'routestat',
})
}
for (const route of app.routes) {
const routeKey = `routes:${app.id}:${route.id}`
if (starredIds.has(routeKey)) {
items.push({
starKey: routeKey,
label: route.name,
path: `/routes/${app.id}/${route.id}`,
type: 'routestat',
parentApp: app.name,
})
}
}
} }
return items return items
@@ -196,6 +252,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true') const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true') const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
const setAppsCollapsed = (updater: (v: boolean) => boolean) => { const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
_setAppsCollapsed((prev) => { _setAppsCollapsed((prev) => {
@@ -212,6 +269,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
return next return next
}) })
} }
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
_setRoutesCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
return next
})
}
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred() const { starredIds, isStarred, toggleStar } = useStarred()
@@ -219,6 +284,32 @@ 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)
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
useEffect(() => {
if (!sidebarRevealPath) return
// Uncollapse Applications section if reveal path matches an apps tree node
const matchesAppTree = appNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
if (matchesAppTree && appsCollapsed) {
_setAppsCollapsed(false)
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
}
// Uncollapse Agents section if reveal path matches an agents tree node
const matchesAgentTree = agentNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
if (matchesAgentTree && agentsCollapsed) {
_setAgentsCollapsed(false)
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
}
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
// Build starred items // Build starred items
const starredItems = useMemo( const starredItems = useMemo(
@@ -229,8 +320,15 @@ 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
// the parent route is highlighted (exchanges have no sidebar entry of their own)
const effectiveSelectedPath = location.pathname.startsWith('/exchanges/') && sidebarRevealPath
? sidebarRevealPath
: location.pathname
return ( return (
<aside className={`${styles.sidebar} ${className ?? ''}`}> <aside className={`${styles.sidebar} ${className ?? ''}`}>
{/* Logo */} {/* Logo */}
@@ -299,11 +397,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
{!appsCollapsed && ( {!appsCollapsed && (
<SidebarTree <SidebarTree
nodes={appNodes} nodes={appNodes}
selectedPath={location.pathname} selectedPath={effectiveSelectedPath}
isStarred={isStarred} isStarred={isStarred}
onToggleStar={toggleStar} onToggleStar={toggleStar}
filterQuery={search} filterQuery={search}
persistKey="cameleer:expanded:apps" persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
/> />
)} )}
</div> </div>
@@ -332,32 +431,48 @@ export function Sidebar({ apps, className }: SidebarProps) {
{!agentsCollapsed && ( {!agentsCollapsed && (
<SidebarTree <SidebarTree
nodes={agentNodes} nodes={agentNodes}
selectedPath={location.pathname} selectedPath={effectiveSelectedPath}
isStarred={isStarred} isStarred={isStarred}
onToggleStar={toggleStar} onToggleStar={toggleStar}
filterQuery={search} filterQuery={search}
persistKey="cameleer:expanded:agents" persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
/> />
)} )}
</div> </div>
{/* Flat nav links */} {/* Routes tree (collapsible, label navigates to /routes) */}
<div className={styles.items}> <div className={styles.treeSection}>
<div <div className={styles.treeSectionToggle}>
className={[ <button
styles.item, className={styles.treeSectionChevronBtn}
location.pathname === '/metrics' ? styles.active : '', onClick={() => setRoutesCollapsed((v) => !v)}
].filter(Boolean).join(' ')} aria-expanded={!routesCollapsed}
onClick={() => navigate('/metrics')} aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
role="button" >
tabIndex={0} {routesCollapsed ? '▸' : '▾'}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }} </button>
> <span
<span className={styles.navIcon}></span> className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
<div className={styles.itemInfo}> onClick={() => navigate('/routes')}
<div className={styles.itemName}>Metrics</div> role="button"
</div> tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
>
Routes
</span>
</div> </div>
{!routesCollapsed && (
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
/>
)}
</div> </div>
{/* No results message */} {/* No results message */}
@@ -396,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>
)} )}
@@ -406,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

@@ -2,6 +2,7 @@ import {
useState, useState,
useRef, useRef,
useCallback, useCallback,
useEffect,
useMemo, useMemo,
type ReactNode, type ReactNode,
type KeyboardEvent, type KeyboardEvent,
@@ -31,6 +32,7 @@ export interface SidebarTreeProps {
className?: string className?: string
filterQuery?: string filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts persistKey?: string // sessionStorage key to persist expand state across remounts
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
} }
// ── Star icon SVGs ─────────────────────────────────────────────────────────── // ── Star icon SVGs ───────────────────────────────────────────────────────────
@@ -138,6 +140,7 @@ export function SidebarTree({
className, className,
filterQuery, filterQuery,
persistKey, persistKey,
autoRevealPath,
}: SidebarTreeProps) { }: SidebarTreeProps) {
const navigate = useNavigate() const navigate = useNavigate()
@@ -146,6 +149,27 @@ export function SidebarTree({
() => persistKey ? readExpandState(persistKey) : new Set(), () => persistKey ? readExpandState(persistKey) : new Set(),
) )
// Auto-expand parent when autoRevealPath changes (e.g. from Cmd-K navigation)
useEffect(() => {
if (!autoRevealPath) return
for (const node of nodes) {
// Check if a child of this node matches the reveal path
if (node.children?.some((child) => child.path === autoRevealPath)) {
if (!userExpandedIds.has(node.id)) {
setUserExpandedIds((prev) => {
const next = new Set(prev)
next.add(node.id)
if (persistKey) writeExpandState(persistKey, next)
return next
})
}
break
}
// Also check if the node itself matches (top-level node, no parent to expand)
if (node.path === autoRevealPath) break
}
}, [autoRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
// Filter // Filter
const { filtered, matchedParentIds } = useMemo( const { filtered, matchedParentIds } = useMemo(
() => filterNodes(nodes, filterQuery ?? ''), () => filterNodes(nodes, filterQuery ?? ''),

View File

@@ -14,7 +14,15 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Center search trigger */ /* Filters group: time range + status pills */
.filters {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* Search trigger */
.search { .search {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -28,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: 280px; width: 200px;
flex: 1; flex-shrink: 1;
max-width: 400px; min-width: 120px;
text-align: left; text-align: left;
} }
@@ -73,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;
@@ -86,16 +115,6 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.shift {
font-family: var(--font-mono);
font-size: 10px;
padding: 3px 10px;
border-radius: 10px;
background: var(--running-bg);
color: var(--running);
border: 1px solid var(--running-border);
}
.user { .user {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,6 +1,12 @@
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 { Avatar } from '../../primitives/Avatar/Avatar' import { Avatar } from '../../primitives/Avatar/Avatar'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider'
interface BreadcrumbItem { interface BreadcrumbItem {
label: string label: string
@@ -10,29 +16,36 @@ interface BreadcrumbItem {
interface TopBarProps { interface TopBarProps {
breadcrumb: BreadcrumbItem[] breadcrumb: BreadcrumbItem[]
environment?: string environment?: string
shift?: string
user?: { name: string } user?: { name: string }
onSearchClick?: () => void
className?: string className?: string
} }
const STATUS_ITEMS: ButtonGroupItem[] = [
{ value: 'completed', label: 'OK', color: 'var(--success)' },
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ value: 'failed', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]
export function TopBar({ export function TopBar({
breadcrumb, breadcrumb,
environment, environment,
shift,
user, user,
onSearchClick,
className, className,
}: TopBarProps) { }: TopBarProps) {
const globalFilters = useGlobalFilters()
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} />
{/* Center: Search trigger */} {/* Search trigger */}
<button <button
className={styles.search} className={styles.search}
onClick={onSearchClick} onClick={() => commandPalette.setOpen(true)}
type="button" type="button"
aria-label="Open search" aria-label="Open search"
> >
@@ -42,18 +55,46 @@ export function TopBar({
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg> </svg>
</span> </span>
<span className={styles.searchPlaceholder}>Search by Order ID, route, error...</span> <span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span> <span className={styles.kbd}>Ctrl+K</span>
</button> </button>
{/* Right: env badge, shift, 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>
)} )}
{shift && (
<span className={styles.shift}>Shift: {shift}</span>
)}
{user && ( {user && (
<div className={styles.user}> <div className={styles.user}>
<span className={styles.userName}>{user.name}</span> <span className={styles.userName}>{user.name}</span>

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

@@ -2,53 +2,7 @@ import { useState } from 'react'
import styles from './DateRangePicker.module.css' import styles from './DateRangePicker.module.css'
import { DateTimePicker } from '../DateTimePicker/DateTimePicker' import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { FilterPill } from '../FilterPill/FilterPill' import { FilterPill } from '../FilterPill/FilterPill'
import { DEFAULT_PRESETS, computePresetRange, type DateRange, type Preset } from '../../utils/timePresets'
interface DateRange {
start: Date
end: Date
}
interface Preset {
label: string
value: string
}
const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
function computePresetRange(preset: string): DateRange {
const now = new Date()
const end = now
switch (preset) {
case 'last-1h':
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
case 'last-6h':
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
case 'today': {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { start, end }
}
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d':
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
default:
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
}
}
interface DateRangePickerProps { interface DateRangePickerProps {
value: DateRange value: DateRange

View File

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

View File

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

View File

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

View File

@@ -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,37 +7,48 @@ 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, ({
count, label,
active = false, count,
dot = false, active = false,
dotColor, dot = false,
onClick, dotColor,
className, activeColor,
}: FilterPillProps) { onClick,
const classes = [ className,
styles.pill, }, ref) => {
active ? styles.active : '', const classes = [
className ?? '', styles.pill,
].filter(Boolean).join(' ') active ? styles.active : '',
active && activeColor ? styles.activeColored : '',
className ?? '',
].filter(Boolean).join(' ')
return ( const activeStyle = active && activeColor
<button className={classes} onClick={onClick} type="button"> ? { borderColor: activeColor, backgroundColor: `color-mix(in srgb, ${activeColor} 12%, transparent)`, color: activeColor } as React.CSSProperties
{dot && ( : undefined
<span
className={styles.dot} return (
style={dotColor ? { background: dotColor } : undefined} <button ref={ref} className={classes} style={activeStyle} onClick={onClick} type="button" data-active={active || undefined}>
/> {dot && (
)} <span
<span className={styles.label}>{label}</span> className={`${styles.dot} ${!active ? styles.dotMuted : ''}`}
{count !== undefined && ( style={dotColor ? { background: active ? dotColor : undefined } : undefined}
<span className={styles.count}>{count}</span> />
)} )}
</button> <span className={styles.label}>{label}</span>
) {count !== undefined && (
} <span className={styles.count}>{count}</span>
)}
</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

@@ -0,0 +1,11 @@
.rangeRow {
display: inline-flex;
align-items: center;
gap: 6px;
}
.rangeSep {
font-size: 12px;
color: var(--text-faint);
flex-shrink: 0;
}

View File

@@ -0,0 +1,100 @@
import { useState, useEffect } from 'react'
import styles from './TimeRangeDropdown.module.css'
import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs'
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { computePresetRange } from '../../utils/timePresets'
import type { TimeRange } from '../../providers/GlobalFilterProvider'
const PRESETS = [
{ value: 'last-1h', label: '1h' },
{ value: 'last-3h', label: '3h' },
{ value: 'last-6h', label: '6h' },
{ value: 'today', label: 'Today' },
{ value: 'last-24h', label: '24h' },
{ value: 'last-7d', label: '7d' },
]
const CUSTOM_VALUE = '__custom__'
interface TimeRangeDropdownProps {
value: TimeRange
onChange: (range: TimeRange) => void
className?: string
}
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
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 (
<div className={className}>
<SegmentedTabs
tabs={PRESETS}
active={activeValue}
onChange={handleTabChange}
trailing={rangeContent}
trailingValue={CUSTOM_VALUE}
/>
</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'
@@ -28,5 +32,6 @@ export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot' export { StatusDot } from './StatusDot/StatusDot'
export { Tag } from './Tag/Tag' export { Tag } from './Tag/Tag'
export { Textarea } from './Textarea/Textarea' export { Textarea } from './Textarea/Textarea'
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
export { Toggle } from './Toggle/Toggle' export { Toggle } from './Toggle/Toggle'
export { Tooltip } from './Tooltip/Tooltip' export { Tooltip } from './Tooltip/Tooltip'

View File

@@ -0,0 +1,28 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
interface CommandPaletteContextValue {
open: boolean
setOpen: (open: boolean) => void
}
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null)
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const [open, setOpenState] = useState(false)
const setOpen = useCallback((value: boolean) => {
setOpenState(value)
}, [])
return (
<CommandPaletteContext.Provider value={{ open, setOpen }}>
{children}
</CommandPaletteContext.Provider>
)
}
export function useCommandPalette(): CommandPaletteContextValue {
const ctx = useContext(CommandPaletteContext)
if (!ctx) throw new Error('useCommandPalette must be used within CommandPaletteProvider')
return ctx
}

View File

@@ -0,0 +1,79 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { computePresetRange } from '../utils/timePresets'
export interface TimeRange {
start: Date
end: Date
preset: string | null
}
export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
interface GlobalFilterContextValue {
timeRange: TimeRange
setTimeRange: (range: TimeRange) => void
statusFilters: Set<ExchangeStatus>
toggleStatus: (status: ExchangeStatus) => void
clearStatusFilters: () => void
isInTimeRange: (timestamp: Date) => boolean
}
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
const DEFAULT_PRESET = 'last-1h'
function getDefaultTimeRange(): TimeRange {
const { start, end } = computePresetRange(DEFAULT_PRESET)
return { start, end, preset: DEFAULT_PRESET }
}
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
const setTimeRange = useCallback((range: TimeRange) => {
setTimeRangeState(range)
}, [])
const toggleStatus = useCallback((status: ExchangeStatus) => {
setStatusFilters((prev) => {
const next = new Set(prev)
if (next.has(status)) {
next.delete(status)
} else {
next.add(status)
}
return next
})
}, [])
const clearStatusFilters = useCallback(() => {
setStatusFilters(new Set())
}, [])
const isInTimeRange = useCallback(
(timestamp: Date) => {
if (timeRange.preset) {
// Recompute from now so the window stays fresh
const { start } = computePresetRange(timeRange.preset)
return timestamp >= start
}
return timestamp >= timeRange.start && timestamp <= timeRange.end
},
[timeRange],
)
return (
<GlobalFilterContext.Provider
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
>
{children}
</GlobalFilterContext.Provider>
)
}
export function useGlobalFilters(): GlobalFilterContextValue {
const ctx = useContext(GlobalFilterContext)
if (!ctx) throw new Error('useGlobalFilters must be used within GlobalFilterProvider')
return ctx
}

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

@@ -0,0 +1,53 @@
export interface DateRange {
start: Date
end: Date
}
export interface Preset {
label: string
value: string
}
export const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
export const PRESET_SHORT_LABELS: Record<string, string> = {
'last-1h': '1h',
'last-3h': '3h',
'last-6h': '6h',
'today': 'Today',
'last-24h': '24h',
'last-7d': '7d',
'custom': 'Custom',
}
export function computePresetRange(preset: string): DateRange {
const now = new Date()
const end = now
switch (preset) {
case 'last-1h':
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
case 'last-3h':
return { start: new Date(now.getTime() - 3 * 60 * 60 * 1000), end }
case 'last-6h':
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
case 'today': {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { start, end }
}
case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d':
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
default:
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
}
}

View File

@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' 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 { 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'
@@ -9,7 +12,13 @@ createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<ThemeProvider> <ThemeProvider>
<App /> <GlobalFilterProvider>
<CommandPaletteProvider>
<ToastProvider>
<App />
</ToastProvider>
</CommandPaletteProvider>
</GlobalFilterProvider>
</ThemeProvider> </ThemeProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,

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],
},
] ]

100
src/mocks/searchData.tsx Normal file
View File

@@ -0,0 +1,100 @@
import type { SearchResult } from '../design-system/composites/CommandPalette/types'
import { exchanges, type Exchange } from './exchanges'
import { routes } from './routes'
import { agents } from './agents'
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function statusLabel(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
}
}
function statusToVariant(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function healthToColor(health: SidebarApp['health']): string {
switch (health) {
case 'live': return 'success'
case 'stale': return 'warning'
case 'dead': return 'error'
}
}
export function buildSearchData(
exs: Exchange[] = exchanges,
rts: typeof routes = routes,
ags: typeof agents = agents,
apps: SidebarApp[] = SIDEBAR_APPS,
): SearchResult[] {
const results: SearchResult[] = []
for (const app of apps) {
const liveAgents = app.agents.filter((a) => a.status === 'live').length
results.push({
id: app.id,
category: 'application',
title: app.name,
badges: [{ label: app.health.toUpperCase(), color: healthToColor(app.health) }],
meta: `${app.routes.length} routes · ${app.agents.length} agents (${liveAgents} live) · ${app.exchangeCount.toLocaleString()} exchanges`,
path: `/apps/${app.id}`,
})
}
for (const exec of exs) {
results.push({
id: exec.id,
category: 'exchange',
title: `${exec.orderId}${exec.route}`,
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
timestamp: formatTimestamp(exec.timestamp),
path: `/exchanges/${exec.id}`,
})
}
const routeToApp = buildRouteToAppMap(apps)
for (const route of rts) {
const appIdForRoute = routeToApp.get(route.id)
results.push({
id: route.id,
category: 'route',
title: route.name,
badges: [{ label: route.group }],
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
path: appIdForRoute ? `/apps/${appIdForRoute}/${route.id}` : `/apps/${route.id}`,
})
}
for (const agent of ags) {
results.push({
id: agent.id,
category: 'agent',
title: agent.name,
badges: [{ label: agent.status }],
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
path: `/agents/${agent.appId}/${agent.id}`,
})
}
return results
}

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"
shift="Day (06:00-18:00)"
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,17 @@ 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
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Mock data // Mock data
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents' import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
@@ -28,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' }
} }
@@ -103,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
} }
@@ -116,13 +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 [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])
@@ -134,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)
// Events are a global timeline feed — show all regardless of scope // Filter events by global time range
const filteredEvents = agentEvents 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"
@@ -155,36 +270,61 @@ export function AgentHealth() {
<div className={styles.content}> <div className={styles.content}>
{/* Stat strip */} {/* Stat strip */}
<div className={styles.statStrip}> <div className={styles.statStrip}>
<StatCard label="Total Instances" value={String(totalInstances)} /> <StatCard
<StatCard label="Live" value={String(liveCount)} accent="success" /> label="Total Agents"
<StatCard label="Stale" value={String(staleCount)} accent={staleCount > 0 ? 'warning' : undefined} /> value={String(totalInstances)}
<StatCard label="Dead" value={String(deadCount)} accent={deadCount > 0 ? 'error' : undefined} /> accent={deadCount > 0 ? 'warning' : 'amber'}
<StatCard label="Total TPS" value={`${totalTps.toFixed(1)}/s`} /> detail={
<StatCard label="Active Routes" value={String(totalActiveRoutes)} /> <span className={styles.breakdown}>
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
</span>
}
/>
<StatCard
label="Applications"
value={String(groups.length)}
accent="running"
detail={
<span className={styles.breakdown}>
<span className={styles.bpLive}><StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy</span>
<span className={styles.bpStale}><StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded</span>
<span className={styles.bpDead}><StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical</span>
</span>
}
/>
<StatCard
label="Active Routes"
value={<span className={styles[totalActiveRoutes === 0 ? 'routesError' : totalActiveRoutes < totalRoutes ? 'routesWarning' : 'routesSuccess']}>{totalActiveRoutes}/{totalRoutes}</span>}
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
/>
<StatCard
label="Total TPS"
value={totalTps.toFixed(1)}
accent="amber"
detail="msg/s"
trend="up"
trendValue="4.2%"
/>
<StatCard
label="Dead"
value={String(deadCount)}
accent={deadCount > 0 ? 'error' : 'success'}
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
/>
</div> </div>
{/* Scope breadcrumb trail */} {/* Scope trail + badges */}
{scope.level !== 'all' && ( <div className={styles.scopeTrail}>
<div className={styles.scopeTrail}> {scope.level !== 'all' && (
<Link to="/agents" className={styles.scopeLink}>All Agents</Link> <>
{scope.level === 'instance' && ( <Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<> <span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeCurrent}>{scope.appId}</span>
<Link to={`/agents/${scope.appId}`} className={styles.scopeLink}>{scope.appId}</Link> </>
</> )}
)}
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeCurrent}>
{scope.level === 'app' ? scope.appId : scope.instanceId}
</span>
</div>
)}
{/* Section header */}
<div className={styles.sectionHeaderRow}>
<span className={styles.sectionTitle}>
{scope.level === 'all' ? 'Agents' : scope.level === 'app' ? scope.appId : scope.instanceId}
</span>
<Badge <Badge
label={`${liveCount}/${totalInstances} live`} label={`${liveCount}/${totalInstances} live`}
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'} color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
@@ -236,78 +376,48 @@ export function AgentHealth() {
</thead> </thead>
<tbody> <tbody>
{group.instances.map((inst) => ( {group.instances.map((inst) => (
<> <tr
<tr key={inst.id}
key={inst.id} className={[
className={[ styles.instanceRow,
styles.instanceRow, selectedInstance?.id === inst.id && panelOpen ? styles.instanceRowActive : '',
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '', ].filter(Boolean).join(' ')}
].filter(Boolean).join(' ')} onClick={() => handleInstanceClick(inst)}
onClick={() => navigate(`/agents/${inst.appId}/${inst.id}`)} >
> <td className={styles.tdStatus}>
<td className={styles.tdStatus}> <StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} /> </td>
</td> <td>
<td> <MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText> </td>
</td> <td>
<td> <Badge
<Badge label={inst.status.toUpperCase()}
label={inst.status.toUpperCase()} color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'} variant="filled"
variant="filled" />
/> </td>
</td> <td>
<td> <MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText> </td>
</td> <td>
<td> <MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText> </td>
</td> <td>
<td> <MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}> {inst.errorRate ?? '0 err/h'}
{inst.errorRate ?? '0 err/h'} </MonoText>
</MonoText> </td>
</td> <td>
<td> <MonoText size="xs" className={
<MonoText size="xs" className={ inst.status === 'dead' ? styles.instanceHeartbeatDead :
inst.status === 'dead' ? styles.instanceHeartbeatDead : inst.status === 'stale' ? styles.instanceHeartbeatStale :
inst.status === 'stale' ? styles.instanceHeartbeatStale : styles.instanceMeta
styles.instanceMeta }>
}> {inst.lastSeen}
{inst.lastSeen} </MonoText>
</MonoText> </td>
</td> </tr>
</tr>
{/* Expanded charts for single instance */}
{singleInstance?.id === inst.id && trendData && (
<tr key={`${inst.id}-charts`} className={styles.chartRow}>
<td colSpan={7}>
<div className={styles.instanceCharts}>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<LineChart
series={[{ label: 'tps', data: trendData.throughput }]}
height={160}
width={480}
yLabel="msg/s"
/>
</div>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Error Rate (err/h)</div>
<LineChart
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
height={160}
width={480}
yLabel="err/h"
/>
</div>
</div>
</td>
</tr>
)}
</>
))} ))}
</tbody> </tbody>
</table> </table>

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ export function ApiDocs() {
<TopBar <TopBar
breadcrumb={[{ label: 'API Documentation' }]} breadcrumb={[{ label: 'API Documentation' }]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<EmptyState <EmptyState

View File

@@ -16,7 +16,7 @@ export function AppDetail() {
{ label: id ?? '' }, { label: id ?? '' },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<EmptyState <EmptyState

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
@@ -8,15 +8,13 @@ import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar' import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites // Composites
import { FilterBar } from '../../design-system/composites/FilterBar/FilterBar'
import type { ActiveFilter } from '../../design-system/composites/FilterBar/FilterBar'
import { DataTable } from '../../design-system/composites/DataTable/DataTable' import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types' 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 { CommandPalette } from '../../design-system/composites/CommandPalette/CommandPalette'
import type { SearchResult } from '../../design-system/composites/CommandPalette/types'
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'
@@ -24,12 +22,16 @@ 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'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Mock data // Mock data
import { exchanges, type Exchange } from '../../mocks/exchanges' import { exchanges, type Exchange } from '../../mocks/exchanges'
import { routes } from '../../mocks/routes'
import { agents } from '../../mocks/agents'
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 {
@@ -39,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' {
@@ -60,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',
@@ -78,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>
), ),
}, },
{ {
@@ -137,58 +143,6 @@ function durationClass(ms: number, status: Exchange['status']): string {
return styles.durBreach return styles.durBreach
} }
// ─── Build CommandPalette search data ────────────────────────────────────────
function buildSearchData(
exs: Exchange[],
rts: typeof routes,
ags: typeof agents,
): SearchResult[] {
const results: SearchResult[] = []
for (const exec of exs) {
results.push({
id: exec.id,
category: 'exchange',
title: `${exec.orderId}${exec.route}`,
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
timestamp: formatTimestamp(exec.timestamp),
})
}
for (const route of rts) {
results.push({
id: route.id,
category: 'route',
title: route.name,
badges: [{ label: route.group }],
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
})
}
for (const agent of ags) {
results.push({
id: agent.id,
category: 'agent',
title: agent.name,
badges: [{ label: agent.status }],
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
})
}
return results
}
function buildStatusFilters(exs: Exchange[]) {
return [
{ label: 'All', value: 'all', count: exs.length },
{ label: 'OK', value: 'completed', count: exs.filter((e) => e.status === 'completed').length, color: 'success' as const },
{ label: 'Warn', value: 'warning', count: exs.filter((e) => e.status === 'warning').length },
{ label: 'Error', value: 'failed', count: exs.filter((e) => e.status === 'failed').length, color: 'error' as const },
{ label: 'Running', value: 'running', count: exs.filter((e) => e.status === 'running').length, color: 'running' as const },
]
}
const SHORTCUTS = [ const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' }, { keys: 'Ctrl+K', label: 'Search' },
{ keys: '↑↓', label: 'Navigate rows' }, { keys: '↑↓', label: 'Navigate rows' },
@@ -198,13 +152,36 @@ 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 [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([]) const navigate = useNavigate()
const [search, setSearch] = useState('')
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)
const [paletteOpen, setPaletteOpen] = useState(false)
// 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()
// Build set of route IDs belonging to the selected app (if any) // Build set of route IDs belonging to the selected app (if any)
const appRouteIds = useMemo(() => { const appRouteIds = useMemo(() => {
@@ -214,55 +191,27 @@ export function Dashboard() {
return new Set(app.routes.map((r) => r.id)) return new Set(app.routes.map((r) => r.id))
}, [appId]) }, [appId])
const selectedApp = appId ? SIDEBAR_APPS.find((a) => a.id === appId) : null // Scope all data to the selected app (and optionally route)
// Scope all data to the selected app
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])
const scopedRoutes = useMemo(() => { // Filter exchanges (scoped + global filters)
if (!appRouteIds) return routes
return routes.filter((r) => appRouteIds.has(r.id))
}, [appRouteIds])
const scopedAgents = useMemo(() => {
if (!selectedApp) return agents
const agentIds = new Set(selectedApp.agents.map((a) => a.id))
return agents.filter((a) => agentIds.has(a.id))
}, [selectedApp])
// Filter exchanges (scoped + user filters)
const filteredExchanges = useMemo(() => { const filteredExchanges = useMemo(() => {
let data = scopedExchanges let data = scopedExchanges
const statusFilter = activeFilters.find((f) => // Time range filter
['completed', 'failed', 'running', 'warning', 'all'].includes(f.value), data = data.filter((e) => isInTimeRange(e.timestamp))
)
if (statusFilter && statusFilter.value !== 'all') {
data = data.filter((e) => e.status === statusFilter.value)
}
if (search.trim()) { // Status filter
const q = search.toLowerCase() if (statusFilters.size > 0) {
data = data.filter( data = data.filter((e) => statusFilters.has(e.status))
(e) =>
e.orderId.toLowerCase().includes(q) ||
e.route.toLowerCase().includes(q) ||
e.customer.toLowerCase().includes(q) ||
e.correlationId.toLowerCase().includes(q) ||
(e.errorMessage?.toLowerCase().includes(q) ?? false),
)
} }
return data return data
}, [activeFilters, search, scopedExchanges]) }, [scopedExchanges, isInTimeRange, statusFilters])
const searchData = useMemo(
() => buildSearchData(scopedExchanges, scopedRoutes, scopedAgents),
[scopedExchanges, scopedRoutes, scopedAgents],
)
function handleRowClick(row: Exchange) { function handleRowClick(row: Exchange) {
setSelectedId(row.id) setSelectedId(row.id)
@@ -276,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={
@@ -379,20 +263,107 @@ export function Dashboard() {
open={panelOpen} open={panelOpen}
onClose={() => setPanelOpen(false)} onClose={() => setPanelOpen(false)}
title={`${selectedExchange.orderId}${selectedExchange.route}`} title={`${selectedExchange.orderId}${selectedExchange.route}`}
tabs={detailTabs} >
/> {/* Link to full detail page */}
<div className={styles.panelSection}>
<button
className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
>
Open full details &#x2192;
</button>
</div>
{/* Overview */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Overview</div>
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(selectedExchange.status)} />
<span>{statusLabel(selectedExchange.status)}</span>
</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span>
<span>{selectedExchange.route}</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Customer</span>
<MonoText size="sm">{selectedExchange.customer}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Agent</span>
<MonoText size="sm">{selectedExchange.agent}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation</span>
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
</div>
</div>
</div>
{/* Errors */}
{totalErrors > 0 && (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>
Errors
{totalErrors > 1 && (
<Badge label={`+${totalErrors - 1} more`} color="error" variant="outlined" />
)}
</div>
<div className={styles.errorBlock}>
<div className={styles.errorClass}>
{selectedExchange.errorClass ?? processorErrors[0]?.name}
</div>
<div className={styles.errorMessage}>
{selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`}
</div>
</div>
</div>
)}
{/* Route Flow */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
<RouteFlow nodes={routeNodes} />
</div>
{/* Processor Timeline */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>
Processor Timeline
<span className={styles.panelSectionMeta}>{formatDuration(selectedExchange.durationMs)}</span>
</div>
<ProcessorTimeline
processors={selectedExchange.processors}
totalMs={selectedExchange.durationMs}
/>
</div>
</DetailPanel>
) : undefined ) : undefined
} }
> >
{/* Top bar */} {/* Top bar */}
<TopBar <TopBar
breadcrumb={appId breadcrumb={
? [{ label: 'Applications', href: '/apps' }, { label: appId }] routeId
: [{ label: 'Applications' }] ? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
: appId
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
: [{ label: 'Applications' }]
} }
environment="PRODUCTION" environment="PRODUCTION"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
onSearchClick={() => setPaletteOpen(true)}
/> />
{/* Scrollable content */} {/* Scrollable content */}
@@ -414,17 +385,6 @@ export function Dashboard() {
))} ))}
</div> </div>
{/* Filter bar */}
<FilterBar
filters={buildStatusFilters(scopedExchanges)}
activeFilters={activeFilters}
onFilterChange={setActiveFilters}
searchPlaceholder="Search by Order ID, correlation ID, error message..."
searchValue={search}
onSearchChange={setSearch}
className={styles.filterBar}
/>
{/* Exchanges table */} {/* Exchanges table */}
<div className={styles.tableSection}> <div className={styles.tableSection}>
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
@@ -443,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 ? (
@@ -459,15 +420,6 @@ export function Dashboard() {
</div> </div>
</div> </div>
{/* Command palette */}
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onSelect={() => setPaletteOpen(false)}
data={searchData}
onOpen={() => setPaletteOpen(true)}
/>
{/* Shortcuts bar */} {/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} /> <ShortcutsBar shortcuts={SHORTCUTS} />
</AppShell> </AppShell>

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"
shift="Day (06:00-18:00)"
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,18 +238,17 @@ 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"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
{/* 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}>
@@ -167,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>
@@ -196,98 +288,168 @@ export function ExchangeDetail() {
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Processor timeline */} {/* Correlation Chain */}
<div className={styles.section}> {correlatedExchanges.length > 1 && (
<div className={styles.sectionHeader}> <div className={styles.correlationChain}>
<span className={styles.sectionTitle}>Processor Timeline</span> <span className={styles.chainLabel}>Correlated Exchanges</span>
<span className={styles.sectionMeta}>Total: {formatDuration(exchange.durationMs)}</span> {correlatedExchanges.map((ce) => {
</div> const isCurrent = ce.id === exchange.id
<div className={styles.timelineWrap}> const variant = statusToVariant(ce.status)
<ProcessorTimeline const statusCls =
processors={exchange.processors} variant === 'success' ? styles.chainNodeSuccess
totalMs={exchange.durationMs} : variant === 'error' ? styles.chainNodeError
/> : variant === 'running' ? styles.chainNodeRunning
</div> : styles.chainNodeWarning
</div> return (
<button
{/* Step-by-step inspector */} key={ce.id}
<div className={styles.section}> className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
<div className={styles.sectionHeader}> onClick={() => {
<span className={styles.sectionTitle}>Exchange Inspector</span> if (!isCurrent) navigate(`/exchanges/${ce.id}`)
<span className={styles.sectionMeta}>{exchange.processors.length} processor steps</span> }}
</div> title={`${ce.id}${ce.route}`}
<div className={styles.inspectorSteps}> >
{exchange.processors.map((proc, index) => { <StatusDot variant={variant} />
const snapshot = generateExchangeSnapshot(proc, exchange.orderId, exchange.customer, index) <span>{ce.route}</span>
const stepStatusClass = </button>
proc.status === 'fail' )
? styles.stepFail })}
: proc.status === 'slow'
? styles.stepSlow
: styles.stepOk
return (
<Collapsible
key={index}
title={
<div className={styles.stepTitle}>
<span className={`${styles.stepIndex} ${stepStatusClass}`}>{index + 1}</span>
<span className={styles.stepName}>{proc.name}</span>
<Badge
label={proc.status.toUpperCase()}
color={proc.status === 'fail' ? 'error' : proc.status === 'slow' ? 'warning' : 'success'}
variant="outlined"
/>
<span className={styles.stepDuration}>{formatDuration(proc.durationMs)}</span>
</div>
}
defaultOpen={proc.status === 'fail'}
className={styles.stepCollapsible}
>
<div className={styles.stepBody}>
<div className={styles.stepPanel}>
<div className={styles.stepPanelLabel}>Exchange Headers</div>
<CodeBlock
content={JSON.stringify(snapshot.headers, null, 2)}
language="json"
copyable
className={styles.codeBlock}
/>
</div>
<div className={styles.stepPanel}>
<div className={styles.stepPanelLabel}>Exchange Body</div>
<CodeBlock
content={snapshot.body}
language="json"
copyable
className={styles.codeBlock}
/>
</div>
</div>
</Collapsible>
)
})}
</div>
</div>
{/* Error block (if failed) */}
{exchange.status === 'failed' && exchange.errorMessage && (
<div className={styles.errorSection}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Error Details</span>
<Badge label="FAILED" color="error" />
</div> </div>
<div className={styles.errorBody}> )}
<div className={styles.errorClass}>{exchange.errorClass}</div> </div>
<pre className={styles.errorMessage}>{exchange.errorMessage}</pre>
<div className={styles.errorHint}> {/* Processor Timeline Section */}
Failed at processor: <MonoText size="xs"> <div className={styles.timelineSection}>
{exchange.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'} <div className={styles.timelineHeader}>
</MonoText> <span className={styles.timelineTitle}>
Processor Timeline
<span className={styles.procCount}>{exchange.processors.length} processors</span>
</span>
<div className={styles.timelineToggle}>
<button
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('gantt')}
>
Timeline
</button>
<button
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('flow')}
>
Flow
</button>
</div>
</div>
<div className={styles.timelineBody}>
{timelineView === 'gantt' ? (
<ProcessorTimeline
processors={exchange.processors}
totalMs={exchange.durationMs}
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
selectedIndex={selectedProcessorIndex}
/>
) : (
<RouteFlow
nodes={routeNodes}
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
selectedIndex={selectedProcessorIndex}
/>
)}
</div>
</div>
{/* Processor Detail Panel (split IN / OUT) */}
{selectedProc && snapshotIn && snapshotOut && (
<div className={styles.detailSplit}>
{/* Message IN */}
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowIn}>&rarr;</span> Message IN
</span>
<span className={styles.panelTag}>at processor #{selectedProcessorIndex + 1} entry</span>
</div>
<div className={styles.panelBody}>
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(snapshotIn.headers).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(snapshotIn.headers).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={snapshotIn.body} language="json" copyable />
</div>
</div> </div>
</div> </div>
{/* Message OUT or Error */}
{isSelectedFailed ? (
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowError}>&times;</span> Error at Processor #{selectedProcessorIndex + 1}
</span>
<Badge label="FAILED" color="error" variant="filled" />
</div>
<div className={styles.panelBody}>
{exchange.errorClass && (
<div className={styles.errorBadgeRow}>
<span className={styles.errorHttpBadge}>{exchange.errorClass.split('.').pop()}</span>
</div>
)}
{exchange.errorMessage && (
<div className={styles.errorMessageBox}>{exchange.errorMessage}</div>
)}
<div className={styles.errorDetailGrid}>
<span className={styles.errorDetailLabel}>Error Class</span>
<span className={styles.errorDetailValue}>{exchange.errorClass ?? 'Unknown'}</span>
<span className={styles.errorDetailLabel}>Processor</span>
<span className={styles.errorDetailValue}>{selectedProc.name}</span>
<span className={styles.errorDetailLabel}>Duration</span>
<span className={styles.errorDetailValue}>{formatDuration(selectedProc.durationMs)}</span>
<span className={styles.errorDetailLabel}>Status</span>
<span className={styles.errorDetailValue}>{selectedProc.status.toUpperCase()}</span>
</div>
</div>
</div>
) : (
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowOut}>&larr;</span> Message OUT
</span>
<span className={styles.panelTag}>after processor #{selectedProcessorIndex + 1}</span>
</div>
<div className={styles.panelBody}>
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(snapshotOut.headers).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(snapshotOut.headers).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={snapshotOut.body} language="json" copyable />
</div>
</div>
</div>
)}
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

@@ -76,7 +76,7 @@ export function LayoutSection() {
> >
<div className={styles.shellDiagram}> <div className={styles.shellDiagram}>
<div className={styles.shellDiagramTop}> <div className={styles.shellDiagramTop}>
TopBar breadcrumb · search · env badge · shift · user avatar TopBar breadcrumb · search · filters · time range · env badge · user avatar
</div> </div>
<div className={styles.shellDiagramBody}> <div className={styles.shellDiagramBody}>
<div className={styles.shellDiagramSide}> <div className={styles.shellDiagramSide}>
@@ -110,7 +110,7 @@ export function LayoutSection() {
<DemoCard <DemoCard
id="topbar" id="topbar"
title="TopBar" title="TopBar"
description="Top navigation bar with breadcrumb, search trigger, environment badge, shift info, and user avatar." description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar."
> >
<div className={styles.topbarPreview}> <div className={styles.topbarPreview}>
<TopBar <TopBar
@@ -120,9 +120,8 @@ export function LayoutSection() {
{ label: 'order-ingest' }, { label: 'order-ingest' },
]} ]}
environment="production" environment="production"
shift="Morning"
user={{ name: 'Hendrik' }} user={{ name: 'Hendrik' }}
onSearchClick={() => undefined}
/> />
</div> </div>
</DemoCard> </DemoCard>

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,146 +0,0 @@
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
/* Date range picker bar */
.dateRangeBar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 10px 16px;
margin-bottom: 16px;
box-shadow: var(--shadow-card);
}
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.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,317 +0,0 @@
import { useState } from 'react'
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 { DateRangePicker } from '../../design-system/primitives/DateRangePicker/DateRangePicker'
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()
const [dateRange, setDateRange] = useState({
start: new Date('2026-03-18T06:00:00'),
end: new Date('2026-03-18T09:15:00'),
})
return (
<AppShell
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/apps' },
{ label: 'Metrics' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Date range picker bar */}
<div className={styles.dateRangeBar}>
<DateRangePicker value={dateRange} onChange={setDateRange} />
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
</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

@@ -210,7 +210,7 @@ export function RouteDetail() {
{ label: id ?? 'Unknown' }, { label: id ?? 'Unknown' },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<div className={styles.content}> <div className={styles.content}>
@@ -236,7 +236,7 @@ export function RouteDetail() {
{ label: route.name }, { label: route.name },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
@@ -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);
}

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