Compare commits

..

70 Commits

Author SHA1 Message Date
hsiegeln
c18ba7d085 fix: exclude e2e tests from CI vitest run
Some checks failed
Build & Publish / publish (push) Failing after 7s
Playwright e2e tests need a browser and can't run in the CI container.
Exclude e2e/ directory from vitest so only unit tests run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:01:26 +01:00
hsiegeln
795ffef9dc feat: add auto-refresh toggle to TopBar and GlobalFilterProvider
Some checks failed
Build & Publish / publish (push) Failing after 5s
Add autoRefresh/setAutoRefresh to GlobalFilterContext, persisted in
localStorage. TopBar shows a LIVE/PAUSED toggle button with pulsing
dot indicator. Consumers can use useGlobalFilters().autoRefresh to
conditionally enable/disable polling intervals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:58:20 +01:00
hsiegeln
039f2fa5fe feat: add onSortChange callback to DataTable for server-side sorting
All checks were successful
Build & Publish / publish (push) Successful in 1m21s
When onSortChange is provided, DataTable operates in controlled mode:
it renders sort indicators and fires the callback on header clicks,
but skips client-side sorting — the caller provides pre-sorted data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:51:22 +01:00
hsiegeln
ac2fb9608f refactor: migrate pages to use new design system components
All checks were successful
Build & Publish / publish (push) Successful in 54s
Dashboard: KpiStrip replaces StatCard strip
Routes: KpiStrip + Card title + removed ~250 lines of KPI/chart CSS
AgentInstance: LogViewer replaces custom log rendering
Admin RBAC: SplitPane + EntityList replaces custom split-pane layout
CLAUDE.md: updated import examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:31:14 +01:00
hsiegeln
8926627c5c docs: update CLAUDE.md import examples with new components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:29:57 +01:00
hsiegeln
bd4e22eafb refactor: use SplitPane and EntityList in Admin RBAC tabs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:22:56 +01:00
hsiegeln
eb62c80daf refactor: use LogViewer in AgentInstance page
Replace custom log entry rendering (MonoText, Badge, per-entry HTML) with
the LogViewer composite component. Map mock data to LogEntry interface,
remove formatLogTime helper, and clean up unused CSS classes and imports
(Card, CodeBlock, ProgressBar, sectionHeaderRow, sectionTitle, fdRow).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:22:41 +01:00
hsiegeln
043f631eac refactor: use KpiStrip, StatusText, and Card title in Routes page
Replace the custom KpiHeader function with KpiStrip composite, swap
chart wrapper divs with Card title prop, and remove ~190 lines of
now-redundant CSS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:21:51 +01:00
hsiegeln
2a78f1535e refactor: use KpiStrip and StatusText in Dashboard page
Replace StatCard loop with KpiStrip composite, map KpiMetric mock data
to KpiItem interface. Remove unused .healthStrip CSS class.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:20:35 +01:00
hsiegeln
65ad955b97 feat: promote mock UI patterns into design system
New components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText
Extended: Card (title prop)
Refactored: AgentHealth to use DataTable
Updated: COMPONENT_GUIDE.md, Inventory demos

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:16:03 +01:00
hsiegeln
80678a0d61 docs: add COMPONENT_GUIDE entries and Inventory demos for KpiStrip, SplitPane, EntityList, LogViewer, StatusText, Card title
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:35:11 +01:00
hsiegeln
08bac437f7 refactor: replace raw HTML tables in AgentHealth with DataTable composite
Replace hand-rolled <table>/<thead>/<tbody> markup in the AgentHealth page
with the existing DataTable composite, using column definitions with custom
render functions for StatusDot, Badge, and MonoText cells. Uses flush prop
for seamless GroupCard integration and pageSize=50 to avoid pagination.
Removes unused table-specific CSS classes (.instanceTable, .instanceRow,
.thStatus, .tdStatus, .instanceRowActive, .instanceCountBadge).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:29:45 +01:00
hsiegeln
8c1c953259 feat: add LogViewer composite for timestamped, severity-colored log display
Scrollable log viewer with auto-scroll behavior, level badges (info/warn/
error/debug) with semantic colors, monospace font, and role="log" for
accessibility. Includes 7 tests and barrel exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:27:03 +01:00
hsiegeln
4abf80144e feat: add EntityList composite for searchable, selectable item lists
Generic list component with render props for item content, search input,
add button, selection highlighting, and keyboard navigation support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:23:56 +01:00
hsiegeln
5fe7752b46 feat: add SplitPane composite for two-column list/detail layouts
Two-column grid layout with configurable ratio (1:1, 1:2, 2:3), list/detail
slots, and empty state message. Uses CSS custom property for dynamic grid
columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:20:53 +01:00
hsiegeln
22c098f9b6 feat: add KpiStrip composite for horizontal metric card rows
Horizontal grid of KPI cards with labels, values, trend indicators,
subtitles, and optional sparklines. Uses CSS custom property for
per-card accent border color. 12 tests included.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:17:33 +01:00
hsiegeln
c89c163068 feat(Card): add optional title prop with uppercase monospace header
When a title string is provided, renders an uppercase monospace h3 header
with a subtle border separator above the card body. Children are wrapped
in a padded body div when title is present; without title, children render
directly as before (no breaking change).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:14:08 +01:00
hsiegeln
f00dc797f2 feat: add StatusText primitive with semantic color variants
Inline <span> component supporting success, warning, error, running, and
muted variants with optional bold styling. Includes 7 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:12:20 +01:00
hsiegeln
e664e449c3 docs: add 4 implementation plans for mock deviation cleanup
Plan 1: KpiStrip + StatusText + Card title (metrics)
Plan 2: SplitPane + EntityList (admin)
Plan 3: LogViewer + AgentHealth DataTable refactor (observability)
Plan 4: COMPONENT_GUIDE.md + Inventory updates (documentation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:10:00 +01:00
hsiegeln
b168d7c867 docs: add EntityList, docs updates, and inventory to spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:54:52 +01:00
hsiegeln
c4cb2b2e31 docs: fix KpiStrip trend coloring and Card relationship in spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:51:17 +01:00
hsiegeln
ef28c0b546 docs: add mock UI deviations design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:48:04 +01:00
hsiegeln
a62b69b8e2 feat: add LoginForm and LoginDialog to component inventory
All checks were successful
Build & Publish / publish (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:26:40 +01:00
hsiegeln
ff4ba9bb91 feat: add LoginForm and LoginDialog components
Composable login components with social provider support,
client-side validation, and full dark mode compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:23:39 +01:00
hsiegeln
c1cb9fa536 feat: export LoginForm and LoginDialog from composites barrel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:18:40 +01:00
hsiegeln
fd9b5e4fef feat: add LoginDialog modal wrapper component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:16:30 +01:00
hsiegeln
ec0db5a011 test: add validation and interaction tests for LoginForm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:14:05 +01:00
hsiegeln
bda0d11fde feat: add LoginForm component with rendering tests
Implements the LoginForm composite with social login, email/password
credentials, remember me, forgot password, and sign up sections.
All sections conditionally render based on provided props.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:07:13 +01:00
hsiegeln
5c02b52cb0 docs: add login dialog implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:00:44 +01:00
hsiegeln
be23161582 docs: fix onSubmit optionality in login dialog spec
Make onSubmit optional so the social-only variant works cleanly
without requiring a no-op callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:47:50 +01:00
hsiegeln
6521bbcf44 docs: add login dialog design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:46:27 +01:00
hsiegeln
b959edd6c7 feat: add logout dropdown to user menu in TopBar
All checks were successful
Build & Publish / publish (push) Successful in 47s
Click user name/avatar to open dropdown with Logout option.
TopBar accepts optional onLogout callback prop.

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

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

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

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

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

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

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

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

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

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

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

Applied to TopBar status pills and EventFeed severity pills.

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

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

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

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

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

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

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

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

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

Applied in Dashboard and RouteDetail "Recent Exchanges" tables.

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

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

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

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

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

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

View File

@@ -7,7 +7,7 @@ on:
jobs:
publish:
runs-on: linux-arm64
runs-on: ubuntu-latest
container:
image: node:22-bookworm-slim
steps:
@@ -17,23 +17,27 @@ jobs:
run: npm ci
- name: Run tests
run: npx vitest run
run: npx vitest run --exclude 'e2e/**'
- name: Build library
run: npm run build:lib
- name: Publish package
shell: bash
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
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.)
### 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
- Semantic color variants: `'success' | 'warning' | 'error'` pattern
- Barrel exports: `src/design-system/primitives/index.ts` and `src/design-system/composites/index.ts`
@@ -37,8 +37,8 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
### Import Paths
```tsx
import { Button, Input } from '../design-system/primitives'
import { Modal, DataTable } from '../design-system/composites'
import type { Column } from '../design-system/composites'
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell'
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
```
@@ -91,10 +91,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
```tsx
// All components from single entry
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
// Types
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
// Providers
import { ThemeProvider, useTheme } from '@cameleer/design-system'

View File

@@ -10,6 +10,7 @@
- Page-level attention banner → **Alert**
- Temporary non-blocking feedback → **Toast** (via `useToast`)
- Destructive action confirmation → **AlertDialog**
- Destructive action needing typed confirmation → **ConfirmDialog**
- Generic dialog with custom content → **Modal**
### "I need a form input"
@@ -19,6 +20,8 @@
- Yes/no with label → **Checkbox**
- One of N options (≤5) → **RadioGroup** + **RadioItem**
- One of N options (>5) → **Select**
- Select multiple from a list → **MultiSelect**
- Edit text inline without a form → **InlineEdit**
- Date/time → **DateTimePicker**
- Date range → **DateRangePicker**
- Wrap any input with label/error/hint → **FormField**
@@ -30,6 +33,7 @@
### "I need to show status"
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
- Labeled status → **Badge** with semantic color
- Removable label → **Tag**
@@ -52,30 +56,43 @@
- Categorical comparison → **BarChart**
- Inline trend → **Sparkline**
- Event log → **EventFeed**
- Processing pipeline → **ProcessorTimeline**
- Processing pipeline (Gantt view)**ProcessorTimeline**
- Processing pipeline (flow diagram) → **RouteFlow**
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
### "I need to organize content"
- Collapsible sections (standalone) → **Collapsible**
- Multiple collapsible sections (one/many open) → **Accordion**
- Tabbed content → **Tabs**
- Tab switching with pill/segment style → **SegmentedTabs**
- Side panel inspector → **DetailPanel**
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
- Section with title + action → **SectionHeader**
- Empty content placeholder → **EmptyState**
- Grouped content box → **Card** (with optional accent)
- Grouped content box → **Card** (with optional accent and title)
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
### "I need to display text"
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
- Monospace inline text → **MonoText**
- Keyboard shortcut hint → **KeyboardHint**
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
### "I need to show people/users"
- Single user avatar → **Avatar**
- 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"
- 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**
- Select multiple from a list → **MultiSelect**
## Composition Patterns
@@ -104,11 +121,20 @@ Row of StatCard components (each with optional Sparkline and trend)
Below: charts (AreaChart, LineChart, BarChart)
```
### Master/detail management pattern
```
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
EntityList provides: search header, add button, selectable list
SplitPane provides: responsive two-column layout with empty state
```
### Detail/inspector pattern
```
DetailPanel (right slide) with Tabs for sections
Each tab: Cards with data, CodeBlock for payloads,
ProcessorTimeline for exchange flow
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
Tabbed: use tabs prop for multiple panels
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
@@ -148,44 +174,55 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| BarChart | composite | Categorical data comparison, optional stacking |
| Breadcrumb | composite | Navigation path showing current location |
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
| Card | primitive | Content container with optional accent border |
| 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 and title header |
| Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
| Collapsible | primitive | Single expand/collapse section |
| CommandPalette | composite | Full-screen search and command interface |
| 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 |
| 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 |
| EmptyState | primitive | Placeholder for empty content areas |
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
| EventFeed | composite | Chronological event log with severity |
| 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. |
| 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 |
| 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 |
| KeyboardHint | primitive | Keyboard shortcut display |
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
| Label | primitive | Form label with optional required asterisk |
| LineChart | composite | Time series line visualization |
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
| MenuItem | composite | Sidebar navigation item with health/count |
| 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) |
| Pagination | primitive | Page navigation controls |
| 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 |
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
| RadioItem | primitive | Individual radio option within RadioGroup |
| 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 |
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
| Sparkline | primitive | Inline mini chart for trends |
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
| Spinner | primitive | Animated loading indicator |
| StatCard | primitive | KPI card with value, trend, optional sparkline |
| StatusDot | primitive | Colored dot for status indication |
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
| Tabs | composite | Tabbed content switcher with optional counts |
| Tag | primitive | Removable colored label |
| Textarea | primitive | Multi-line text input with resize control |
@@ -199,8 +236,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
| Component | Purpose |
|-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | Hierarchical navigation with Applications/Agents trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
| TopBar | Header bar with breadcrumb, environment, user info |
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
## Import Paths

111
README.md Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,573 @@
# Admin Components 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:** Add SplitPane and EntityList composites to provide reusable master/detail layout and searchable entity list patterns, replacing ~150 lines of duplicated CSS and structure across admin RBAC tabs.
**Architecture:** SplitPane is a layout-only component providing a two-column grid with configurable ratio. EntityList provides a searchable, selectable list with render props for item content. They compose together naturally: EntityList slots into SplitPane's list panel.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 2, 2b)
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `src/design-system/composites/SplitPane/SplitPane.tsx` | Create | Two-column grid layout with list/detail slots and empty state |
| `src/design-system/composites/SplitPane/SplitPane.module.css` | Create | Grid layout, scrollable panels, empty state styling |
| `src/design-system/composites/SplitPane/SplitPane.test.tsx` | Create | 5 test cases for SplitPane |
| `src/design-system/composites/EntityList/EntityList.tsx` | Create | Generic searchable, selectable list with render props |
| `src/design-system/composites/EntityList/EntityList.module.css` | Create | Header, scrollable list, item hover/selected states |
| `src/design-system/composites/EntityList/EntityList.test.tsx` | Create | 11 test cases for EntityList |
| `src/design-system/composites/index.ts` | Modify | Add SplitPane and EntityList exports |
---
### Task 1: SplitPane composite
**Files:**
- Create: `src/design-system/composites/SplitPane/SplitPane.tsx`
- Create: `src/design-system/composites/SplitPane/SplitPane.module.css`
- Create: `src/design-system/composites/SplitPane/SplitPane.test.tsx`
- [ ] **Step 1: Write SplitPane tests**
Create `src/design-system/composites/SplitPane/SplitPane.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SplitPane } from './SplitPane'
describe('SplitPane', () => {
it('renders list and detail content', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={<div>User detail</div>}
/>,
)
expect(screen.getByText('User list')).toBeInTheDocument()
expect(screen.getByText('User detail')).toBeInTheDocument()
})
it('shows default empty message when detail is null', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={null}
/>,
)
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
})
it('shows custom empty message when detail is null', () => {
render(
<SplitPane
list={<div>User list</div>}
detail={null}
emptyMessage="Pick a user to see info"
/>,
)
expect(screen.getByText('Pick a user to see info')).toBeInTheDocument()
})
it('renders with different ratios', () => {
const { container, rerender } = render(
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="1:1" />,
)
const pane = container.firstChild as HTMLElement
expect(pane.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
rerender(
<SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="2:3" />,
)
expect(pane.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
})
it('accepts className', () => {
const { container } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
className="custom"
/>,
)
expect(container.firstChild).toHaveClass('custom')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create SplitPane CSS module**
Create `src/design-system/composites/SplitPane/SplitPane.module.css`:
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.splitPane`, `.listPane`, `.detailPane`, `.emptyDetail`), generalized with a CSS custom property for the column ratio.
```css
.splitPane {
display: grid;
grid-template-columns: var(--split-columns, 1fr 2fr);
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 0;
height: 100%;
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);
overflow-y: auto;
}
.detailPane {
background: var(--bg-raised);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-faint);
font-size: 13px;
font-family: var(--font-body);
font-style: italic;
}
```
- [ ] **Step 4: Create SplitPane component**
Create `src/design-system/composites/SplitPane/SplitPane.tsx`:
```tsx
import type { ReactNode } from 'react'
import styles from './SplitPane.module.css'
interface SplitPaneProps {
list: ReactNode
detail: ReactNode | null
emptyMessage?: string
ratio?: '1:1' | '1:2' | '2:3'
className?: string
}
const ratioMap: Record<string, string> = {
'1:1': '1fr 1fr',
'1:2': '1fr 2fr',
'2:3': '2fr 3fr',
}
export function SplitPane({
list,
detail,
emptyMessage = 'Select an item to view details',
ratio = '1:2',
className,
}: SplitPaneProps) {
return (
<div
className={`${styles.splitPane} ${className ?? ''}`}
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
>
<div className={styles.listPane}>{list}</div>
<div className={styles.detailPane}>
{detail !== null ? detail : (
<div className={styles.emptyDetail}>{emptyMessage}</div>
)}
</div>
</div>
)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx`
Expected: 5 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/design-system/composites/SplitPane/SplitPane.tsx \
src/design-system/composites/SplitPane/SplitPane.module.css \
src/design-system/composites/SplitPane/SplitPane.test.tsx
git commit -m "feat: add SplitPane composite for master/detail layouts"
```
---
### Task 2: EntityList composite
**Files:**
- Create: `src/design-system/composites/EntityList/EntityList.tsx`
- Create: `src/design-system/composites/EntityList/EntityList.module.css`
- Create: `src/design-system/composites/EntityList/EntityList.test.tsx`
- [ ] **Step 1: Write EntityList tests**
Create `src/design-system/composites/EntityList/EntityList.test.tsx`:
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EntityList } from './EntityList'
interface TestItem {
id: string
name: string
}
const items: TestItem[] = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
{ id: '3', name: 'Charlie' },
]
const defaultProps = {
items,
renderItem: (item: TestItem) => <span>{item.name}</span>,
getItemId: (item: TestItem) => item.id,
}
describe('EntityList', () => {
it('renders all items', () => {
render(<EntityList {...defaultProps} />)
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
expect(screen.getByText('Charlie')).toBeInTheDocument()
})
it('calls onSelect when item clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onSelect={onSelect} />)
await user.click(screen.getByText('Bob'))
expect(onSelect).toHaveBeenCalledWith('2')
})
it('highlights selected item', () => {
render(<EntityList {...defaultProps} selectedId="2" />)
const selectedOption = screen.getByText('Bob').closest('[role="option"]')
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
expect(selectedOption).toHaveClass(/selected/i)
})
it('renders search input when onSearch provided', () => {
render(<EntityList {...defaultProps} onSearch={vi.fn()} searchPlaceholder="Search users..." />)
expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument()
})
it('calls onSearch when typing in search', async () => {
const onSearch = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onSearch={onSearch} />)
await user.type(screen.getByPlaceholderText('Search...'), 'alice')
expect(onSearch).toHaveBeenLastCalledWith('alice')
})
it('renders add button when onAdd provided', () => {
render(<EntityList {...defaultProps} onAdd={vi.fn()} addLabel="+ Add user" />)
expect(screen.getByRole('button', { name: '+ Add user' })).toBeInTheDocument()
})
it('calls onAdd when add button clicked', async () => {
const onAdd = vi.fn()
const user = userEvent.setup()
render(<EntityList {...defaultProps} onAdd={onAdd} addLabel="+ Add user" />)
await user.click(screen.getByRole('button', { name: '+ Add user' }))
expect(onAdd).toHaveBeenCalledOnce()
})
it('hides header when no search or add', () => {
const { container } = render(<EntityList {...defaultProps} />)
// No header element should be rendered (no search input, no add button)
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
expect(container.querySelector('[class*="listHeader"]')).not.toBeInTheDocument()
})
it('shows empty message when items is empty', () => {
render(
<EntityList
items={[]}
renderItem={() => <span />}
getItemId={() => ''}
/>,
)
expect(screen.getByText('No items found')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<EntityList
items={[]}
renderItem={() => <span />}
getItemId={() => ''}
emptyMessage="No users match your search"
/>,
)
expect(screen.getByText('No users match your search')).toBeInTheDocument()
})
it('accepts className', () => {
const { container } = render(<EntityList {...defaultProps} className="custom" />)
expect(container.firstChild).toHaveClass('custom')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create EntityList CSS module**
Create `src/design-system/composites/EntityList/EntityList.module.css`:
CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.listHeader`, `.listHeaderSearch`, `.entityList`, `.entityItem`, `.entityItemSelected`), generalized for reuse.
```css
.entityListRoot {
display: flex;
flex-direction: column;
height: 100%;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeaderSearch {
flex: 1;
}
.list {
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(--amber-bg);
border-left: 3px solid var(--amber);
}
.emptyMessage {
padding: 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
```
- [ ] **Step 4: Create EntityList component**
Create `src/design-system/composites/EntityList/EntityList.tsx`:
The component uses `role="listbox"` / `role="option"` for accessibility, matching the pattern in `UsersTab.tsx`. It delegates search input and add button to the existing `Input` and `Button` primitives.
```tsx
import { useState, type ReactNode } from 'react'
import { Input } from '../../primitives/Input/Input'
import { Button } from '../../primitives/Button/Button'
import styles from './EntityList.module.css'
interface EntityListProps<T> {
items: T[]
renderItem: (item: T, isSelected: boolean) => ReactNode
getItemId: (item: T) => string
selectedId?: string
onSelect?: (id: string) => void
searchPlaceholder?: string
onSearch?: (query: string) => void
addLabel?: string
onAdd?: () => void
emptyMessage?: string
className?: string
}
export function EntityList<T>({
items,
renderItem,
getItemId,
selectedId,
onSelect,
searchPlaceholder = 'Search...',
onSearch,
addLabel,
onAdd,
emptyMessage = 'No items found',
className,
}: EntityListProps<T>) {
const [searchValue, setSearchValue] = useState('')
const showHeader = !!onSearch || !!onAdd
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setSearchValue(value)
onSearch?.(value)
}
function handleSearchClear() {
setSearchValue('')
onSearch?.('')
}
return (
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
{showHeader && (
<div className={styles.listHeader}>
{onSearch && (
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={handleSearchChange}
onClear={handleSearchClear}
className={styles.listHeaderSearch}
/>
)}
{onAdd && addLabel && (
<Button size="sm" variant="secondary" onClick={onAdd}>
{addLabel}
</Button>
)}
</div>
)}
<div className={styles.list} role="listbox">
{items.map((item) => {
const id = getItemId(item)
const isSelected = id === selectedId
return (
<div
key={id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => onSelect?.(id)}
role="option"
tabIndex={0}
aria-selected={isSelected}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect?.(id)
}
}}
>
{renderItem(item, isSelected)}
</div>
)
})}
{items.length === 0 && (
<div className={styles.emptyMessage}>{emptyMessage}</div>
)}
</div>
</div>
)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx`
Expected: 11 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/design-system/composites/EntityList/EntityList.tsx \
src/design-system/composites/EntityList/EntityList.module.css \
src/design-system/composites/EntityList/EntityList.test.tsx
git commit -m "feat: add EntityList composite for searchable, selectable lists"
```
---
### Task 3: Barrel exports & full test suite
**Files:**
- Modify: `src/design-system/composites/index.ts`
- [ ] **Step 1: Add exports to barrel**
Add these lines to `src/design-system/composites/index.ts` in alphabetical position.
After the `DetailPanel` export (line 13), add:
```ts
export { EntityList } from './EntityList/EntityList'
```
After the `LineChart` export (line 19), before `LoginDialog`, add:
```ts
// (no change needed here — LoginDialog is already present)
```
After the `ShortcutsBar` export (line 33), before `SegmentedTabs`, add:
```ts
export { SplitPane } from './SplitPane/SplitPane'
```
The resulting new lines in `index.ts` (in their alphabetical positions):
```ts
export { EntityList } from './EntityList/EntityList'
```
```ts
export { SplitPane } from './SplitPane/SplitPane'
```
- [ ] **Step 2: Run the full component test suite**
Run: `npx vitest run src/design-system/composites/SplitPane/ src/design-system/composites/EntityList/`
Expected: All 16 tests PASS (5 SplitPane + 11 EntityList)
- [ ] **Step 3: Run the full project test suite to check for regressions**
Run: `npx vitest run`
Expected: All tests PASS
- [ ] **Step 4: Commit**
```bash
git add src/design-system/composites/index.ts
git commit -m "feat: export SplitPane and EntityList from composites barrel"
```

View File

@@ -0,0 +1,431 @@
# Documentation Updates 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:** Update COMPONENT_GUIDE.md and Inventory page with entries and demos for all new components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText, and Card title extension.
**Architecture:** COMPONENT_GUIDE.md gets new decision tree entries and component index rows. Inventory page gets DemoCard sections with realistic sample data for each new component.
**Tech Stack:** React, TypeScript, CSS Modules
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Documentation Updates section)
---
## Task 1: Update COMPONENT_GUIDE.md
**File:** `COMPONENT_GUIDE.md`
### Steps
- [ ] **1a.** In the `"I need to show status"` decision tree (line ~34), add StatusText entry after StatusDot:
```markdown
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
```
- [ ] **1b.** In the `"I need to display data"` decision tree (line ~51), add three entries after the EventFeed line:
```markdown
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
```
- [ ] **1c.** In the `"I need to organize content"` decision tree (line ~62), add SplitPane entry after DetailPanel and update the Card entry:
After the `- Side panel inspector → **DetailPanel**` line, add:
```markdown
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
```
Update the existing Card line from:
```markdown
- Grouped content box → **Card** (with optional accent)
```
to:
```markdown
- Grouped content box → **Card** (with optional accent and title)
```
- [ ] **1d.** In the `"I need to display text"` decision tree (line ~72), add StatusText cross-reference:
```markdown
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
```
- [ ] **1e.** Add a new composition pattern after the existing "KPI dashboard" pattern (line ~113):
```markdown
### Master/detail management pattern
```
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
EntityList provides: search header, add button, selectable list
SplitPane provides: responsive two-column layout with empty state
```
```
- [ ] **1f.** Add five new rows to the Component Index table (maintaining alphabetical order):
After the `EventFeed` row:
```markdown
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
```
After the `KeyboardHint` row:
```markdown
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
```
After the `LineChart` row:
```markdown
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
```
After the `Sparkline` row:
```markdown
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
```
- [ ] **1g.** Update the existing `Card` row in the Component Index from:
```markdown
| Card | primitive | Content container with optional accent border |
```
to:
```markdown
| Card | primitive | Content container with optional accent border and title header |
```
---
## Task 2: Add StatusText demo to PrimitivesSection
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
### Steps
- [ ] **2a.** Add `StatusText` to the import from `'../../../design-system/primitives'` (insert alphabetically after `StatCard`):
```tsx
StatusText,
```
- [ ] **2b.** Add a new DemoCard after the StatusDot demo (after line ~560, before the Tag demo). Insert this block:
```tsx
{/* 29. StatusText */}
<DemoCard
id="statustext"
title="StatusText"
description="Inline coloured text for status values — five semantic variants with optional bold."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<div className={styles.demoAreaRow}>
<StatusText variant="success">99.8% uptime</StatusText>
<StatusText variant="warning">SLA at risk</StatusText>
<StatusText variant="error">BREACH</StatusText>
<StatusText variant="running">Processing</StatusText>
<StatusText variant="muted">N/A</StatusText>
</div>
<div className={styles.demoAreaRow}>
<StatusText variant="success" bold>99.8% uptime</StatusText>
<StatusText variant="warning" bold>SLA at risk</StatusText>
<StatusText variant="error" bold>BREACH</StatusText>
<StatusText variant="running" bold>Processing</StatusText>
<StatusText variant="muted" bold>N/A</StatusText>
</div>
</div>
</DemoCard>
```
Note: Renumber subsequent demos (Tag becomes 30, Textarea becomes 31, Toggle becomes 32, Tooltip becomes 33).
---
## Task 3: Update Card demo in PrimitivesSection
**File:** `src/pages/Inventory/sections/PrimitivesSection.tsx`
### Steps
- [ ] **3a.** Update the Card DemoCard description from:
```tsx
description="Surface container with optional left-border accent colour."
```
to:
```tsx
description="Surface container with optional left-border accent colour and title header."
```
- [ ] **3b.** Add a title prop example to the Card demo. After the existing `Card accent="error"` line (~212), add:
```tsx
<Card title="Throughput (msg/s)">
<div style={{ padding: '8px 12px', fontSize: 13 }}>Card with title header and separator</div>
</Card>
<Card accent="amber" title="Error Rate">
<div style={{ padding: '8px 12px', fontSize: 13 }}>Title + accent combined</div>
</Card>
```
---
## Task 4: Add composite demos to CompositesSection
**File:** `src/pages/Inventory/sections/CompositesSection.tsx`
### Steps
- [ ] **4a.** Add new imports. Add `KpiStrip`, `SplitPane`, `EntityList`, `LogViewer` to the import from `'../../../design-system/composites'` (insert alphabetically):
```tsx
EntityList,
KpiStrip,
LogViewer,
SplitPane,
```
Also add `Badge` and `Avatar` to the import from `'../../../design-system/primitives'` (needed for EntityList demo renderItem):
```tsx
import { Avatar, Badge, Button } from '../../../design-system/primitives'
```
- [ ] **4b.** Add sample data constants after the existing sample data section (before the `CompositesSection` function). Add:
```tsx
// ── Sample data for new composites ───────────────────────────────────────────
const KPI_ITEMS = [
{
label: 'Exchanges',
value: '12,847',
trend: { label: '↑ +8.2%', variant: 'success' as const },
subtitle: 'Last 24h',
sparkline: [40, 55, 48, 62, 70, 65, 78],
borderColor: 'var(--amber)',
},
{
label: 'Error Rate',
value: '0.34%',
trend: { label: '↑ +0.12pp', variant: 'error' as const },
subtitle: 'Above threshold',
sparkline: [10, 12, 11, 15, 18, 22, 19],
borderColor: 'var(--error)',
},
{
label: 'Avg Latency',
value: '142ms',
trend: { label: '↓ -12ms', variant: 'success' as const },
subtitle: 'P95: 380ms',
borderColor: 'var(--success)',
},
{
label: 'Active Routes',
value: '37',
trend: { label: '±0', variant: 'muted' as const },
subtitle: '3 paused',
borderColor: 'var(--running)',
},
]
const ENTITY_LIST_ITEMS = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
{ id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'Editor' },
{ id: '3', name: 'Carol Smith', email: 'carol@example.com', role: 'Viewer' },
{ id: '4', name: 'David Park', email: 'david@example.com', role: 'Editor' },
{ id: '5', name: 'Eva Martinez', email: 'eva@example.com', role: 'Admin' },
]
const LOG_ENTRIES = [
{ timestamp: '2026-03-24T10:00:01Z', level: 'info' as const, message: 'Route timer-aggregator started successfully' },
{ timestamp: '2026-03-24T10:00:03Z', level: 'debug' as const, message: 'Polling endpoint https://api.internal/health — 200 OK' },
{ timestamp: '2026-03-24T10:00:15Z', level: 'warn' as const, message: 'Retry queue depth at 847 — approaching threshold (1000)' },
{ timestamp: '2026-03-24T10:00:22Z', level: 'error' as const, message: 'Exchange failed: Connection refused to jdbc:postgresql://db-primary:5432/orders' },
{ timestamp: '2026-03-24T10:00:23Z', level: 'info' as const, message: 'Failover activated — routing to db-secondary' },
{ timestamp: '2026-03-24T10:00:30Z', level: 'info' as const, message: 'Exchange completed in 142ms via fallback route' },
{ timestamp: '2026-03-24T10:00:45Z', level: 'debug' as const, message: 'Metrics flush: 328 data points written to InfluxDB' },
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn' as const, message: 'Memory usage at 78% — GC scheduled' },
]
```
- [ ] **4c.** Add state variables inside the `CompositesSection` function for EntityList demo:
```tsx
// EntityList state
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>('1')
const [entitySearch, setEntitySearch] = useState('')
```
- [ ] **4d.** Add KpiStrip demo after the existing GroupCard demo. Insert a new DemoCard:
```tsx
{/* KpiStrip */}
<DemoCard
id="kpistrip"
title="KpiStrip"
description="Horizontal row of KPI cards with coloured left border, trend indicator, subtitle, and optional sparkline."
>
<div style={{ width: '100%' }}>
<KpiStrip items={KPI_ITEMS} />
</div>
</DemoCard>
```
- [ ] **4e.** Add SplitPane demo:
```tsx
{/* SplitPane */}
<DemoCard
id="splitpane"
title="SplitPane"
description="Two-column master/detail layout with configurable ratio and empty-state placeholder."
>
<div style={{ width: '100%', height: 200 }}>
<SplitPane
list={
<div style={{ padding: 16, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Items</div>
<div>Item A</div>
<div>Item B</div>
<div>Item C</div>
</div>
}
detail={
<div style={{ padding: 16, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Detail View</div>
<div>Select an item on the left to see its details here.</div>
</div>
}
ratio="1:2"
/>
</div>
</DemoCard>
```
- [ ] **4f.** Add EntityList demo:
```tsx
{/* EntityList */}
<DemoCard
id="entitylist"
title="EntityList"
description="Searchable, selectable entity list with add button — designed to pair with SplitPane."
>
<div style={{ width: '100%', height: 260 }}>
<EntityList
items={ENTITY_LIST_ITEMS.filter(u =>
u.name.toLowerCase().includes(entitySearch.toLowerCase())
)}
renderItem={(item, isSelected) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar name={item.name} size="sm" />
<div>
<div style={{ fontSize: 13, fontWeight: isSelected ? 600 : 400 }}>{item.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.email}</div>
</div>
<Badge label={item.role} style={{ marginLeft: 'auto' }} />
</div>
)}
getItemId={(item) => item.id}
selectedId={selectedEntityId}
onSelect={setSelectedEntityId}
searchPlaceholder="Search users..."
onSearch={setEntitySearch}
addLabel="+ Add user"
onAdd={() => {}}
/>
</div>
</DemoCard>
```
- [ ] **4g.** Add LogViewer demo:
```tsx
{/* LogViewer */}
<DemoCard
id="logviewer"
title="LogViewer"
description="Scrollable log output with timestamped, severity-coloured monospace entries and auto-scroll."
>
<div style={{ width: '100%' }}>
<LogViewer entries={LOG_ENTRIES} maxHeight={240} />
</div>
</DemoCard>
```
- [ ] **4h.** Verify all four new DemoCards are placed in alphabetical order among existing demos — EntityList after EventFeed, KpiStrip after GroupCard, LogViewer after LoginForm, SplitPane after ShortcutsBar. Adjust comment numbering accordingly.
---
## Task 5: Update Inventory nav
**File:** `src/pages/Inventory/Inventory.tsx`
### Steps
- [ ] **5a.** Add `StatusText` to the Primitives nav components array (insert alphabetically after `StatusDot`):
```tsx
{ label: 'StatusText', href: '#statustext' },
```
- [ ] **5b.** Add four entries to the Composites nav components array (insert alphabetically):
After `EventFeed`:
```tsx
{ label: 'EntityList', href: '#entitylist' },
```
After `GroupCard`:
```tsx
{ label: 'KpiStrip', href: '#kpistrip' },
```
After `LoginForm`:
```tsx
{ label: 'LogViewer', href: '#logviewer' },
```
After `ShortcutsBar`:
```tsx
{ label: 'SplitPane', href: '#splitpane' },
```
---
## Task 6: Commit all documentation
### Steps
- [ ] **6a.** Run `npx vitest run src/pages/Inventory` to verify Inventory page has no import/type errors (if tests exist for it).
- [ ] **6b.** Stage changed files:
- `COMPONENT_GUIDE.md`
- `src/pages/Inventory/Inventory.tsx`
- `src/pages/Inventory/sections/PrimitivesSection.tsx`
- `src/pages/Inventory/sections/CompositesSection.tsx`
- [ ] **6c.** Commit with message: `docs: add COMPONENT_GUIDE entries and Inventory demos for KpiStrip, SplitPane, EntityList, LogViewer, StatusText, Card title`
---
## Dependency Notes
- **Tasks 1-5 are independent** and can be worked in any order.
- **Task 6 depends on Tasks 1-5** being complete.
- **All tasks depend on the components already existing** — StatusText, Card title extension, KpiStrip, SplitPane, EntityList, and LogViewer must be built and exported from their barrel files before the Inventory demos will compile.
## Files Modified
| File | Change |
|------|--------|
| `COMPONENT_GUIDE.md` | Decision tree entries + component index rows |
| `src/pages/Inventory/Inventory.tsx` | 5 new nav entries (1 primitive + 4 composites) |
| `src/pages/Inventory/sections/PrimitivesSection.tsx` | StatusText demo + Card title demo update |
| `src/pages/Inventory/sections/CompositesSection.tsx` | KpiStrip, SplitPane, EntityList, LogViewer demos with sample data |

View File

@@ -0,0 +1,770 @@
# Login Dialog 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:** Add composable `LoginForm` and `LoginDialog` components to the Cameleer3 design system with credential + social login support, client-side validation, and full dark mode compatibility.
**Architecture:** `LoginForm` is the core content component with all form logic, validation, and layout. `LoginDialog` is a thin wrapper that renders `LoginForm` inside `Modal size="sm"`. Both live in `src/design-system/composites/LoginForm/` and are exported from the composites barrel.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-login-dialog-design.md`
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `src/design-system/composites/LoginForm/LoginForm.tsx` | Create | Core form component with validation, social providers, all layout |
| `src/design-system/composites/LoginForm/LoginForm.module.css` | Create | All styles using design tokens |
| `src/design-system/composites/LoginForm/LoginForm.test.tsx` | Create | 21 test cases for LoginForm |
| `src/design-system/composites/LoginForm/LoginDialog.tsx` | Create | Thin Modal wrapper |
| `src/design-system/composites/LoginForm/LoginDialog.test.tsx` | Create | 5 test cases for LoginDialog |
| `src/design-system/composites/index.ts` | Modify | Add LoginForm, LoginDialog, and type exports |
---
### Task 1: LoginForm — Rendering Tests & Basic Structure
**Files:**
- Create: `src/design-system/composites/LoginForm/LoginForm.tsx`
- Create: `src/design-system/composites/LoginForm/LoginForm.module.css`
- Create: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
- [ ] **Step 1: Write rendering tests**
Create `src/design-system/composites/LoginForm/LoginForm.test.tsx`:
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LoginForm } from './LoginForm'
const socialProviders = [
{ label: 'Continue with Google', onClick: vi.fn() },
{ label: 'Continue with GitHub', onClick: vi.fn() },
]
const allProps = {
logo: <div data-testid="logo">Logo</div>,
title: 'Welcome back',
socialProviders,
onSubmit: vi.fn(),
onForgotPassword: vi.fn(),
onSignUp: vi.fn(),
}
describe('LoginForm', () => {
describe('rendering', () => {
it('renders all elements when all props provided', () => {
render(<LoginForm {...allProps} />)
expect(screen.getByTestId('logo')).toBeInTheDocument()
expect(screen.getByText('Welcome back')).toBeInTheDocument()
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
expect(screen.getByText('or')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
})
it('renders default title when title prop omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.getByText('Sign in')).toBeInTheDocument()
})
it('hides social section when socialProviders is empty', () => {
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
expect(screen.queryByText('or')).not.toBeInTheDocument()
})
it('hides social section when socialProviders is omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText('or')).not.toBeInTheDocument()
})
it('hides forgot password link when onForgotPassword omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
})
it('hides sign up link when onSignUp omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
})
it('hides credentials section when onSubmit omitted (social only)', () => {
render(<LoginForm socialProviders={socialProviders} />)
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
expect(screen.queryByText('or')).not.toBeInTheDocument()
// Social buttons should still render
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
})
it('shows server error Alert when error prop set', () => {
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
})
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create LoginForm component with basic rendering**
Create `src/design-system/composites/LoginForm/LoginForm.module.css`:
```css
.loginForm {
display: flex;
flex-direction: column;
align-items: center;
font-family: var(--font-body);
width: 100%;
}
.logo {
margin-bottom: 8px;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px;
}
.error {
width: 100%;
margin-bottom: 16px;
}
.socialSection {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-bottom: 20px;
}
.socialButton {
width: 100%;
justify-content: center;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-bottom: 20px;
}
.dividerLine {
flex: 1;
height: 1px;
background: var(--border);
}
.dividerText {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.fields {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
.rememberRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.forgotLink {
font-size: 11px;
color: var(--amber);
font-weight: 500;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--font-body);
}
.forgotLink:hover {
text-decoration: underline;
}
.submitButton {
width: 100%;
}
.signUpText {
text-align: center;
font-size: 12px;
color: var(--text-secondary);
}
.signUpLink {
color: var(--amber);
font-weight: 500;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--font-body);
font-size: 12px;
}
.signUpLink:hover {
text-decoration: underline;
}
```
Create `src/design-system/composites/LoginForm/LoginForm.tsx`:
```tsx
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
import { Button } from '../../primitives/Button/Button'
import { Input } from '../../primitives/Input/Input'
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
import { FormField } from '../../primitives/FormField/FormField'
import { Alert } from '../../primitives/Alert/Alert'
import styles from './LoginForm.module.css'
export interface SocialProvider {
label: string
icon?: ReactNode
onClick: () => void
}
export interface LoginFormProps {
logo?: ReactNode
title?: string
socialProviders?: SocialProvider[]
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
onForgotPassword?: () => void
onSignUp?: () => void
error?: string
loading?: boolean
className?: string
}
interface FieldErrors {
email?: string
password?: string
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function validate(email: string, password: string): FieldErrors {
const errors: FieldErrors = {}
if (!email) {
errors.email = 'Email is required'
} else if (!EMAIL_REGEX.test(email)) {
errors.email = 'Please enter a valid email address'
}
if (!password) {
errors.password = 'Password is required'
} else if (password.length < 8) {
errors.password = 'Password must be at least 8 characters'
}
return errors
}
export function LoginForm({
logo,
title = 'Sign in',
socialProviders,
onSubmit,
onForgotPassword,
onSignUp,
error,
loading = false,
className,
}: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [submitted, setSubmitted] = useState(false)
const emailRef = useRef<HTMLInputElement>(null)
// Auto-focus first input on mount
useEffect(() => {
emailRef.current?.focus()
}, [])
// Reset submitted flag when error prop changes (new server error from re-attempt)
useEffect(() => {
if (error) setSubmitted(false)
}, [error])
// Server error is shown from prop, hidden after next submit attempt
const showServerError = error && !submitted
const hasSocial = socialProviders && socialProviders.length > 0
const hasCredentials = !!onSubmit
const showDivider = hasSocial && hasCredentials
function handleSubmit(e: FormEvent) {
e.preventDefault()
setSubmitted(true)
const errors = validate(email, password)
setFieldErrors(errors)
if (Object.keys(errors).length === 0) {
onSubmit?.({ email, password, remember })
}
}
return (
<div className={`${styles.loginForm} ${className ?? ''}`}>
{logo && <div className={styles.logo}>{logo}</div>}
<h2 className={styles.title}>{title}</h2>
{showServerError && (
<div className={styles.error}>
<Alert variant="error">{error}</Alert>
</div>
)}
{hasSocial && (
<div className={styles.socialSection}>
{socialProviders.map((provider) => (
<Button
key={provider.label}
variant="secondary"
className={styles.socialButton}
onClick={provider.onClick}
disabled={loading}
type="button"
>
{provider.icon}
{provider.label}
</Button>
))}
</div>
)}
{showDivider && (
<div className={styles.divider}>
<div className={styles.dividerLine} />
<span className={styles.dividerText}>or</span>
<div className={styles.dividerLine} />
</div>
)}
{hasCredentials && (
<form
className={styles.fields}
onSubmit={handleSubmit}
aria-label="Sign in"
noValidate
>
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
<Input
ref={emailRef}
id="login-email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
}}
disabled={loading}
/>
</FormField>
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
<Input
id="login-password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
}}
disabled={loading}
/>
</FormField>
<div className={styles.rememberRow}>
<Checkbox
label="Remember me"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={loading}
/>
{onForgotPassword && (
<button
type="button"
className={styles.forgotLink}
onClick={onForgotPassword}
>
Forgot password?
</button>
)}
</div>
<Button
variant="primary"
type="submit"
loading={loading}
className={styles.submitButton}
>
Sign in
</Button>
{onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</form>
)}
{!hasCredentials && onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</div>
)
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
Expected: 8 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/design-system/composites/LoginForm/LoginForm.tsx \
src/design-system/composites/LoginForm/LoginForm.module.css \
src/design-system/composites/LoginForm/LoginForm.test.tsx
git commit -m "feat: add LoginForm component with rendering tests"
```
---
### Task 2: LoginForm — Validation Tests & Behavior
**Files:**
- Modify: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
- [ ] **Step 1: Add validation and interaction tests**
Append to the `describe('LoginForm')` block in `LoginForm.test.tsx`:
```tsx
import userEvent from '@testing-library/user-event'
// Add these inside the existing describe('LoginForm') block, after the rendering describe:
describe('validation', () => {
it('validates required email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
})
it('validates email format', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'notanemail')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
})
it('validates required password', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Password is required')).toBeInTheDocument()
})
it('validates password minimum length', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'short')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
})
it('clears field errors on typing', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
await user.type(screen.getByLabelText(/email/i), 't')
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
})
it('calls onSubmit with credentials when valid', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByLabelText(/remember me/i))
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
remember: true,
})
})
it('does not call onSubmit when validation fails', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('loading state', () => {
it('disables form inputs when loading', () => {
render(<LoginForm {...allProps} loading />)
expect(screen.getByLabelText(/email/i)).toBeDisabled()
expect(screen.getByLabelText(/password/i)).toBeDisabled()
expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
})
it('shows spinner on submit button when loading', () => {
render(<LoginForm {...allProps} loading />)
const submitBtn = screen.getByRole('button', { name: 'Sign in' })
expect(submitBtn).toBeDisabled()
// Button component renders Spinner when loading=true
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
})
it('disables social buttons when loading', () => {
render(<LoginForm {...allProps} loading />)
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
})
})
describe('callbacks', () => {
it('calls social provider onClick when clicked', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
expect(onClick).toHaveBeenCalledOnce()
})
it('calls onForgotPassword when link clicked', async () => {
const onForgotPassword = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
await user.click(screen.getByText(/forgot password/i))
expect(onForgotPassword).toHaveBeenCalledOnce()
})
it('calls onSignUp when link clicked', async () => {
const onSignUp = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
await user.click(screen.getByText(/sign up/i))
expect(onSignUp).toHaveBeenCalledOnce()
})
})
```
- [ ] **Step 2: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
Expected: 21 tests PASS (8 rendering + 7 validation + 3 loading + 3 callbacks)
- [ ] **Step 3: Commit**
```bash
git add src/design-system/composites/LoginForm/LoginForm.test.tsx
git commit -m "test: add validation and interaction tests for LoginForm"
```
---
### Task 3: LoginDialog — Component & Tests
**Files:**
- Create: `src/design-system/composites/LoginForm/LoginDialog.tsx`
- Create: `src/design-system/composites/LoginForm/LoginDialog.test.tsx`
- [ ] **Step 1: Write LoginDialog tests**
Create `src/design-system/composites/LoginForm/LoginDialog.test.tsx`:
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginDialog } from './LoginDialog'
const defaultProps = {
open: true,
onClose: vi.fn(),
onSubmit: vi.fn(),
}
describe('LoginDialog', () => {
it('renders Modal with LoginForm when open', () => {
render(<LoginDialog {...defaultProps} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Sign in')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<LoginDialog {...defaultProps} open={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('calls onClose on Esc', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<LoginDialog {...defaultProps} onClose={onClose} />)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on backdrop click', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<LoginDialog {...defaultProps} onClose={onClose} />)
await user.click(screen.getByTestId('modal-backdrop'))
expect(onClose).toHaveBeenCalled()
})
it('passes LoginForm props through', () => {
render(
<LoginDialog
{...defaultProps}
title="Welcome"
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
error="Bad credentials"
/>,
)
expect(screen.getByText('Welcome')).toBeInTheDocument()
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Create LoginDialog component**
Create `src/design-system/composites/LoginForm/LoginDialog.tsx`:
```tsx
import { Modal } from '../Modal/Modal'
import { LoginForm, type LoginFormProps } from './LoginForm'
export interface LoginDialogProps extends LoginFormProps {
open: boolean
onClose: () => void
}
export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
return (
<Modal open={open} onClose={onClose} size="sm" className={className}>
<LoginForm {...formProps} />
</Modal>
)
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
Expected: 5 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/design-system/composites/LoginForm/LoginDialog.tsx \
src/design-system/composites/LoginForm/LoginDialog.test.tsx
git commit -m "feat: add LoginDialog modal wrapper component"
```
---
### Task 4: Barrel Exports & Full Test Suite
**Files:**
- Modify: `src/design-system/composites/index.ts`
- [ ] **Step 1: Add exports to barrel**
Add these lines to `src/design-system/composites/index.ts` in alphabetical position (after the `LineChart` export, before `MenuItem`):
```ts
export { LoginForm } from './LoginForm/LoginForm'
export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
export { LoginDialog } from './LoginForm/LoginDialog'
export type { LoginDialogProps } from './LoginForm/LoginDialog'
```
- [ ] **Step 2: Run the full test suite**
Run: `npx vitest run src/design-system/composites/LoginForm/`
Expected: All tests PASS (21 LoginForm + 5 LoginDialog = 26 tests)
- [ ] **Step 3: Run the full project test suite to check for regressions**
Run: `npx vitest run`
Expected: All tests PASS
- [ ] **Step 4: Commit**
```bash
git add src/design-system/composites/index.ts
git commit -m "feat: export LoginForm and LoginDialog from composites barrel"
```

View File

@@ -0,0 +1,703 @@
# Metrics Components 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:** Add StatusText primitive, Card title prop, and KpiStrip composite to eliminate ~320 lines of duplicated KPI layout code across Dashboard, Routes, and AgentHealth pages.
**Architecture:** StatusText is a tiny inline span primitive with semantic color variants. Card gets an optional title prop for a header row. KpiStrip is a new composite that renders a horizontal row of metric cards with labels, values, trends, subtitles, and sparklines.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 1, 5, 6)
---
## File Map
| Action | File | Task |
|--------|------|------|
| CREATE | `src/design-system/primitives/StatusText/StatusText.tsx` | 1 |
| CREATE | `src/design-system/primitives/StatusText/StatusText.module.css` | 1 |
| CREATE | `src/design-system/primitives/StatusText/StatusText.test.tsx` | 1 |
| MODIFY | `src/design-system/primitives/index.ts` | 1 |
| MODIFY | `src/design-system/primitives/Card/Card.tsx` | 2 |
| MODIFY | `src/design-system/primitives/Card/Card.module.css` | 2 |
| CREATE | `src/design-system/primitives/Card/Card.test.tsx` | 2 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.tsx` | 3 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.module.css` | 3 |
| CREATE | `src/design-system/composites/KpiStrip/KpiStrip.test.tsx` | 3 |
| MODIFY | `src/design-system/composites/index.ts` | 3 |
---
## Task 1: StatusText Primitive
**Files:**
- CREATE `src/design-system/primitives/StatusText/StatusText.tsx`
- CREATE `src/design-system/primitives/StatusText/StatusText.module.css`
- CREATE `src/design-system/primitives/StatusText/StatusText.test.tsx`
- MODIFY `src/design-system/primitives/index.ts`
### Step 1.1 — Write test (RED)
- [ ] Create `src/design-system/primitives/StatusText/StatusText.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { StatusText } from './StatusText'
describe('StatusText', () => {
it('renders children text', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK')).toBeInTheDocument()
})
it('renders as a span element', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK').tagName).toBe('SPAN')
})
it('applies variant class', () => {
render(<StatusText variant="error">BREACH</StatusText>)
expect(screen.getByText('BREACH')).toHaveClass('error')
})
it('applies bold class when bold=true', () => {
render(<StatusText variant="warning" bold>HIGH</StatusText>)
expect(screen.getByText('HIGH')).toHaveClass('bold')
})
it('does not apply bold class by default', () => {
render(<StatusText variant="muted">idle</StatusText>)
expect(screen.getByText('idle')).not.toHaveClass('bold')
})
it('accepts custom className', () => {
render(<StatusText variant="running" className="custom">active</StatusText>)
expect(screen.getByText('active')).toHaveClass('custom')
})
it('renders all variant classes correctly', () => {
const { rerender } = render(<StatusText variant="success">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('success')
rerender(<StatusText variant="warning">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('warning')
rerender(<StatusText variant="error">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('error')
rerender(<StatusText variant="running">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('running')
rerender(<StatusText variant="muted">text</StatusText>)
expect(screen.getByText('text')).toHaveClass('muted')
})
})
```
- [ ] Run test — expect FAIL (module not found):
```bash
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
```
### Step 1.2 — Implement (GREEN)
- [ ] Create `src/design-system/primitives/StatusText/StatusText.module.css`:
```css
.statusText {
/* Inherits font-size from parent */
}
.success { color: var(--success); }
.warning { color: var(--warning); }
.error { color: var(--error); }
.running { color: var(--running); }
.muted { color: var(--text-muted); }
.bold { font-weight: 600; }
```
- [ ] Create `src/design-system/primitives/StatusText/StatusText.tsx`:
```tsx
import styles from './StatusText.module.css'
import type { ReactNode } from 'react'
interface StatusTextProps {
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
bold?: boolean
children: ReactNode
className?: string
}
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
const classes = [
styles.statusText,
styles[variant],
bold ? styles.bold : '',
className ?? '',
].filter(Boolean).join(' ')
return <span className={classes}>{children}</span>
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx
```
### Step 1.3 — Barrel export
- [ ] Add to `src/design-system/primitives/index.ts` (alphabetical, after `StatusDot`):
```ts
export { StatusText } from './StatusText/StatusText'
```
### Step 1.4 — Commit
```bash
git add src/design-system/primitives/StatusText/ src/design-system/primitives/index.ts
git commit -m "feat: add StatusText primitive with semantic color variants"
```
---
## Task 2: Card Title Extension
**Files:**
- MODIFY `src/design-system/primitives/Card/Card.tsx`
- MODIFY `src/design-system/primitives/Card/Card.module.css`
- CREATE `src/design-system/primitives/Card/Card.test.tsx`
### Step 2.1 — Write test (RED)
- [ ] Create `src/design-system/primitives/Card/Card.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('renders children', () => {
render(<Card>Card content</Card>)
expect(screen.getByText('Card content')).toBeInTheDocument()
})
it('renders title when provided', () => {
render(<Card title="Section Title">content</Card>)
expect(screen.getByText('Section Title')).toBeInTheDocument()
})
it('does not render title header when title is omitted', () => {
const { container } = render(<Card>content</Card>)
expect(container.querySelector('.titleHeader')).not.toBeInTheDocument()
})
it('wraps children in body div when title is provided', () => {
render(<Card title="Header">body text</Card>)
const body = screen.getByText('body text').closest('div')
expect(body).toHaveClass('body')
})
it('renders with accent and title together', () => {
const { container } = render(
<Card accent="success" title="Status">
details
</Card>
)
expect(container.firstChild).toHaveClass('accent-success')
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('details')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<Card className="custom">content</Card>)
expect(container.firstChild).toHaveClass('custom')
})
it('renders children directly when no title (no wrapper div)', () => {
const { container } = render(<Card><span data-testid="direct">hi</span></Card>)
expect(screen.getByTestId('direct')).toBeInTheDocument()
// Should not have a body wrapper when there is no title
expect(container.querySelector('.body')).not.toBeInTheDocument()
})
})
```
- [ ] Run test — expect FAIL (title prop not supported yet, body class missing):
```bash
npx vitest run src/design-system/primitives/Card/Card.test.tsx
```
### Step 2.2 — Implement (GREEN)
- [ ] Add to `src/design-system/primitives/Card/Card.module.css` (append after existing rules):
```css
.titleHeader {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.titleText {
font-size: 11px;
text-transform: uppercase;
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.5px;
margin: 0;
}
.body {
padding: 16px;
}
```
- [ ] Replace `src/design-system/primitives/Card/Card.tsx` with:
```tsx
import styles from './Card.module.css'
import type { ReactNode } from 'react'
interface CardProps {
children: ReactNode
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
title?: string
className?: string
}
export function Card({ children, accent = 'none', title, className }: CardProps) {
const classes = [
styles.card,
accent !== 'none' ? styles[`accent-${accent}`] : '',
className ?? '',
].filter(Boolean).join(' ')
return (
<div className={classes}>
{title && (
<div className={styles.titleHeader}>
<h3 className={styles.titleText}>{title}</h3>
</div>
)}
{title ? <div className={styles.body}>{children}</div> : children}
</div>
)
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/primitives/Card/Card.test.tsx
```
### Step 2.3 — Commit
```bash
git add src/design-system/primitives/Card/
git commit -m "feat: add optional title prop to Card primitive"
```
---
## Task 3: KpiStrip Composite
**Files:**
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.tsx`
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.module.css`
- CREATE `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`
- MODIFY `src/design-system/composites/index.ts`
### Step 3.1 — Write test (RED)
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { KpiStrip } from './KpiStrip'
const sampleItems = [
{
label: 'Total Throughput',
value: '12,847',
trend: { label: '\u25B2 +8%', variant: 'success' as const },
subtitle: '35.7 msg/s',
sparkline: [44, 46, 45, 47, 48, 46, 47],
borderColor: 'var(--amber)',
},
{
label: 'Error Rate',
value: '0.42%',
trend: { label: '\u25BC -0.1%', variant: 'success' as const },
subtitle: '54 errors / 12,847 total',
},
{
label: 'Active Routes',
value: 14,
},
]
describe('KpiStrip', () => {
it('renders all items', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('Total Throughput')).toBeInTheDocument()
expect(screen.getByText('Error Rate')).toBeInTheDocument()
expect(screen.getByText('Active Routes')).toBeInTheDocument()
})
it('renders labels and values', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('12,847')).toBeInTheDocument()
expect(screen.getByText('0.42%')).toBeInTheDocument()
expect(screen.getByText('14')).toBeInTheDocument()
})
it('renders trend with correct text', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('\u25B2 +8%')).toBeInTheDocument()
expect(screen.getByText('\u25BC -0.1%')).toBeInTheDocument()
})
it('applies variant class to trend', () => {
render(<KpiStrip items={sampleItems} />)
const trend = screen.getByText('\u25B2 +8%')
expect(trend).toHaveClass('trendSuccess')
})
it('hides trend when omitted', () => {
render(<KpiStrip items={[{ label: 'Routes', value: 14 }]} />)
// Should only have label and value, no trend element
const card = screen.getByText('Routes').closest('[class*="kpiCard"]')
expect(card?.querySelector('[class*="trend"]')).toBeNull()
})
it('renders subtitle', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('35.7 msg/s')).toBeInTheDocument()
expect(screen.getByText('54 errors / 12,847 total')).toBeInTheDocument()
})
it('renders sparkline when data provided', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
// Sparkline renders an SVG with aria-hidden
const svgs = container.querySelectorAll('svg[aria-hidden="true"]')
expect(svgs.length).toBe(1) // Only first item has sparkline
})
it('accepts className prop', () => {
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
expect(container.firstChild).toHaveClass('custom')
})
it('handles empty items array', () => {
const { container } = render(<KpiStrip items={[]} />)
expect(container.firstChild).toBeInTheDocument()
// No cards rendered
expect(container.querySelectorAll('[class*="kpiCard"]').length).toBe(0)
})
it('uses default border color when borderColor is omitted', () => {
const { container } = render(
<KpiStrip items={[{ label: 'Test', value: 100 }]} />
)
const card = container.querySelector('[class*="kpiCard"]')
expect(card).toBeInTheDocument()
// The default borderColor is applied via inline style
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--amber)' })
})
it('applies custom borderColor', () => {
const { container } = render(
<KpiStrip items={[{ label: 'Errors', value: 5, borderColor: 'var(--error)' }]} />
)
const card = container.querySelector('[class*="kpiCard"]')
expect(card).toHaveStyle({ '--kpi-border-color': 'var(--error)' })
})
it('renders trend with muted variant by default', () => {
render(
<KpiStrip items={[{ label: 'Test', value: 1, trend: { label: '~ stable' } }]} />
)
const trend = screen.getByText('~ stable')
expect(trend).toHaveClass('trendMuted')
})
})
```
- [ ] Run test — expect FAIL (module not found):
```bash
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
```
### Step 3.2 — Implement (GREEN)
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.module.css`:
```css
/* KpiStrip — horizontal row of metric cards */
.kpiStrip {
display: grid;
gap: 12px;
margin-bottom: 20px;
}
/* ── Individual 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);
}
/* Top gradient border — color driven by CSS custom property */
.kpiCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
}
/* ── Label ───────────────────────────────────────────────────────── */
.label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 6px;
}
/* ── Value row ───────────────────────────────────────────────────── */
.valueRow {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
}
.value {
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--text-primary);
}
/* ── Trend ────────────────────────────────────────────────────────── */
.trend {
font-family: var(--font-mono);
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.trendSuccess { color: var(--success); }
.trendWarning { color: var(--warning); }
.trendError { color: var(--error); }
.trendMuted { color: var(--text-muted); }
/* ── Subtitle ─────────────────────────────────────────────────────── */
.subtitle {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Sparkline ────────────────────────────────────────────────────── */
.sparkline {
margin-top: 8px;
height: 32px;
}
```
- [ ] Create `src/design-system/composites/KpiStrip/KpiStrip.tsx`:
```tsx
import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties, ReactNode } from 'react'
export interface KpiItem {
label: string
value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string
}
export interface KpiStripProps {
items: KpiItem[]
className?: string
}
const trendClassMap: Record<string, string> = {
success: styles.trendSuccess,
warning: styles.trendWarning,
error: styles.trendError,
muted: styles.trendMuted,
}
export function KpiStrip({ items, className }: KpiStripProps) {
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
const gridStyle: CSSProperties = {
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
}
return (
<div className={stripClasses} style={gridStyle}>
{items.map((item) => {
const borderColor = item.borderColor ?? 'var(--amber)'
const cardStyle: CSSProperties & Record<string, string> = {
'--kpi-border-color': borderColor,
}
const trendVariant = item.trend?.variant ?? 'muted'
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
return (
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
<div className={styles.label}>{item.label}</div>
<div className={styles.valueRow}>
<span className={styles.value}>{item.value}</span>
{item.trend && (
<span className={`${styles.trend} ${trendClass}`}>
{item.trend.label}
</span>
)}
</div>
{item.subtitle && (
<div className={styles.subtitle}>{item.subtitle}</div>
)}
{item.sparkline && item.sparkline.length >= 2 && (
<div className={styles.sparkline}>
<Sparkline
data={item.sparkline}
color={borderColor}
width={200}
height={32}
/>
</div>
)}
</div>
)
})}
</div>
)
}
```
- [ ] Run test — expect PASS:
```bash
npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx
```
### Step 3.3 — Barrel export
- [ ] Add to `src/design-system/composites/index.ts` (alphabetical, after `GroupCard`):
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
### Step 3.4 — Commit
```bash
git add src/design-system/composites/KpiStrip/ src/design-system/composites/index.ts
git commit -m "feat: add KpiStrip composite for reusable metric card rows"
```
---
## Task 4: Barrel Exports Verification & Full Test Run
**Files:**
- VERIFY `src/design-system/primitives/index.ts` (modified in Task 1)
- VERIFY `src/design-system/composites/index.ts` (modified in Task 3)
### Step 4.1 — Verify barrel exports
- [ ] Confirm `src/design-system/primitives/index.ts` contains:
```ts
export { StatusText } from './StatusText/StatusText'
```
- [ ] Confirm `src/design-system/composites/index.ts` contains:
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
### Step 4.2 — Run full test suite
- [ ] Run all tests to confirm nothing is broken:
```bash
npx vitest run
```
- [ ] Verify zero failures. If any test fails, fix and re-run before proceeding.
### Step 4.3 — Final commit (if barrel-only changes remain)
If the barrel export changes were not already committed in their respective tasks:
```bash
git add src/design-system/primitives/index.ts src/design-system/composites/index.ts
git commit -m "chore: add StatusText and KpiStrip to barrel exports"
```
---
## Summary of Expected Barrel Export Additions
**`src/design-system/primitives/index.ts`** — insert after `StatusDot` line:
```ts
export { StatusText } from './StatusText/StatusText'
```
**`src/design-system/composites/index.ts`** — insert after `GroupCard` line:
```ts
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
```
---
## Test Commands Quick Reference
| Scope | Command |
|-------|---------|
| StatusText only | `npx vitest run src/design-system/primitives/StatusText/StatusText.test.tsx` |
| Card only | `npx vitest run src/design-system/primitives/Card/Card.test.tsx` |
| KpiStrip only | `npx vitest run src/design-system/composites/KpiStrip/KpiStrip.test.tsx` |
| All tests | `npx vitest run` |

View File

@@ -0,0 +1,506 @@
# Observability Components 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:** Add LogViewer composite for log display and refactor AgentHealth to use DataTable instead of raw HTML tables.
**Architecture:** LogViewer is a scrollable log display with timestamped, severity-colored entries and auto-scroll behavior. The AgentHealth refactor replaces raw `<table>` elements with the existing DataTable composite.
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 3, 4)
---
## Task 1: LogViewer composite
Create a new composite component that renders a scrollable log viewer with timestamped, severity-colored entries. This replaces the custom log rendering in `AgentInstance.tsx`.
### Files
- **Create** `src/design-system/composites/LogViewer/LogViewer.tsx`
- **Create** `src/design-system/composites/LogViewer/LogViewer.module.css`
- **Create** `src/design-system/composites/LogViewer/LogViewer.test.tsx`
### Steps
- [ ] **1.1** Create `src/design-system/composites/LogViewer/LogViewer.tsx` with the component and exported types
- [ ] **1.2** Create `src/design-system/composites/LogViewer/LogViewer.module.css` with all styles
- [ ] **1.3** Create `src/design-system/composites/LogViewer/LogViewer.test.tsx` with tests
- [ ] **1.4** Run `npx vitest run src/design-system/composites/LogViewer` and fix any failures
### API
```tsx
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string // Default: 400
className?: string
}
```
### Component implementation — `LogViewer.tsx`
```tsx
import { useRef, useEffect, useCallback } from 'react'
import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string
className?: string
}
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
info: styles.levelInfo,
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
} catch {
return iso
}
}
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
// Consider "at bottom" when within 20px of the end
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
}, [])
// Auto-scroll to bottom when entries change, but only if user hasn't scrolled up
useEffect(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) {
el.scrollTop = el.scrollHeight
}
}, [entries])
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
return (
<div
ref={scrollRef}
className={[styles.container, className].filter(Boolean).join(' ')}
style={{ maxHeight: heightStyle }}
onScroll={handleScroll}
role="log"
>
{entries.map((entry, i) => (
<div key={i} className={styles.line}>
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
{entry.level.toUpperCase()}
</span>
<span className={styles.message}>{entry.message}</span>
</div>
))}
{entries.length === 0 && (
<div className={styles.empty}>No log entries.</div>
)}
</div>
)
}
```
### Styles — `LogViewer.module.css`
```css
/* Scrollable container */
.container {
overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0;
font-family: var(--font-mono);
}
/* Each log line */
.line {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 12px;
line-height: 1.5;
}
.line:hover {
background: var(--bg-hover);
}
/* Timestamp */
.timestamp {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
min-width: 56px;
}
/* Level badge — pill with tinted background */
.levelBadge {
flex-shrink: 0;
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 1px 6px;
border-radius: 9999px;
line-height: 1.5;
white-space: nowrap;
}
.levelInfo {
color: var(--running);
background: color-mix(in srgb, var(--running) 12%, transparent);
}
.levelWarn {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.levelError {
color: var(--error);
background: color-mix(in srgb, var(--error) 12%, transparent);
}
.levelDebug {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
/* Message text */
.message {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
word-break: break-word;
line-height: 1.5;
}
/* Empty state */
.empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
```
### Tests — `LogViewer.test.tsx`
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LogViewer, type LogEntry } from './LogViewer'
import { ThemeProvider } from '../../providers/ThemeProvider'
const wrap = (ui: React.ReactElement) => render(<ThemeProvider>{ui}</ThemeProvider>)
const sampleEntries: LogEntry[] = [
{ timestamp: '2026-03-24T10:00:00Z', level: 'info', message: 'Server started' },
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn', message: 'Slow query detected' },
{ timestamp: '2026-03-24T10:02:00Z', level: 'error', message: 'Connection refused' },
{ timestamp: '2026-03-24T10:03:00Z', level: 'debug', message: 'Cache hit ratio: 0.95' },
]
describe('LogViewer', () => {
it('renders entries with timestamps and messages', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByText('Server started')).toBeInTheDocument()
expect(screen.getByText('Slow query detected')).toBeInTheDocument()
expect(screen.getByText('Connection refused')).toBeInTheDocument()
expect(screen.getByText('Cache hit ratio: 0.95')).toBeInTheDocument()
})
it('renders level badges with correct text', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument()
})
it('renders with custom maxHeight', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight={200} />)
const el = container.querySelector('[role="log"]')
expect(el).toHaveStyle({ maxHeight: '200px' })
})
it('renders with string maxHeight', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight="50vh" />)
const el = container.querySelector('[role="log"]')
expect(el).toHaveStyle({ maxHeight: '50vh' })
})
it('handles empty entries', () => {
wrap(<LogViewer entries={[]} />)
expect(screen.getByText('No log entries.')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = wrap(<LogViewer entries={sampleEntries} className="custom-class" />)
const el = container.querySelector('[role="log"]')
expect(el?.className).toContain('custom-class')
})
it('has role="log" for accessibility', () => {
wrap(<LogViewer entries={sampleEntries} />)
expect(screen.getByRole('log')).toBeInTheDocument()
})
})
```
### Key design decisions
- **Auto-scroll behavior:** Uses a `useRef` to track whether the user is at the bottom of the scroll container. On new entries (via `useEffect` on `entries`), scrolls to bottom only if `isAtBottomRef.current` is `true`. Pauses when user scrolls up (more than 20px from bottom). Resumes when user scrolls back to bottom.
- **Level colors:** Map to existing design tokens: `info` -> `var(--running)`, `warn` -> `var(--warning)`, `error` -> `var(--error)`, `debug` -> `var(--text-muted)`. Pill backgrounds use `color-mix` with 12% opacity tint.
- **No Badge dependency:** The level badge is a styled `<span>` rather than using the `Badge` primitive. This avoids pulling in `hashColor`/`useTheme` and keeps the badge styling tightly scoped (9px pill vs Badge's larger size). The spec calls for a very compact pill at 9px mono — a custom element is cleaner.
- **`role="log"`** on the container for accessibility (indicates a log region to screen readers).
---
## Task 2: Barrel exports for LogViewer
Add LogViewer and its types to the composites barrel export.
### Files
- **Modify** `src/design-system/composites/index.ts`
### Steps
- [ ] **2.1** Add LogViewer export and type exports to `src/design-system/composites/index.ts`
### Changes
Add these lines to `src/design-system/composites/index.ts`, in alphabetical position (after the `LineChart` export):
```ts
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
```
The full insertion point — after line 19 (`export { LineChart } from './LineChart/LineChart'`) and before line 20 (`export { LoginDialog } from './LoginForm/LoginDialog'`):
```ts
export { LineChart } from './LineChart/LineChart'
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
export { LoginDialog } from './LoginForm/LoginDialog'
```
---
## Task 3: AgentHealth DataTable refactor
Replace the raw HTML `<table>` in `AgentHealth.tsx` with the existing `DataTable` composite. This is a **page-level refactor** — no design system components are changed.
### Files
- **Modify** `src/pages/AgentHealth/AgentHealth.tsx` — replace `<table>` with `<DataTable>`
- **Modify** `src/pages/AgentHealth/AgentHealth.module.css` — remove table CSS
### Steps
- [ ] **3.1** Add `DataTable` and `Column` imports to `AgentHealth.tsx`
- [ ] **3.2** Define the instance columns array
- [ ] **3.3** Replace the `<table>` block inside each `<GroupCard>` with `<DataTable>`
- [ ] **3.4** Remove unused table CSS classes from `AgentHealth.module.css`
- [ ] **3.5** Visually verify the page looks identical (run dev server, navigate to `/agents`)
### 3.1 — Add imports
Add to the composites import block in `AgentHealth.tsx`:
```tsx
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types'
```
### 3.2 — Define columns
Add a column definition constant above the `AgentHealth` component function. The columns mirror the existing `<th>` headers. Custom `render` functions handle the StatusDot and Badge cells.
**Important:** DataTable requires rows with an `id: string` field. The `AgentHealthData` type already has `id`, so no transformation is needed.
```tsx
const instanceColumns: Column<AgentHealthData>[] = [
{
key: 'status',
header: '',
width: '12px',
render: (_value, row) => (
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
),
},
{
key: 'name',
header: 'Instance',
render: (_value, row) => (
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
),
},
{
key: 'state',
header: 'State',
render: (_value, row) => (
<Badge
label={row.status.toUpperCase()}
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
variant="filled"
/>
),
},
{
key: 'uptime',
header: 'Uptime',
render: (_value, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
),
},
{
key: 'tps',
header: 'TPS',
render: (_value, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
),
},
{
key: 'errorRate',
header: 'Errors',
render: (_value, row) => (
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
{row.errorRate ?? '0 err/h'}
</MonoText>
),
},
{
key: 'lastSeen',
header: 'Heartbeat',
render: (_value, row) => (
<MonoText size="xs" className={
row.status === 'dead' ? styles.instanceHeartbeatDead :
row.status === 'stale' ? styles.instanceHeartbeatStale :
styles.instanceMeta
}>
{row.lastSeen}
</MonoText>
),
},
]
```
### 3.3 — Replace `<table>` with `<DataTable>`
Replace the entire `<table className={styles.instanceTable}>...</table>` block (lines 365-423 of `AgentHealth.tsx`) inside each `<GroupCard>` with:
```tsx
<DataTable
columns={instanceColumns}
data={group.instances}
flush
selectedId={selectedInstance?.id}
onRowClick={handleInstanceClick}
pageSize={50}
/>
```
Key props:
- `flush` — strips DataTable's outer border/radius/shadow so it sits seamlessly inside the GroupCard
- `selectedId` — highlights the currently selected row (replaces the manual `instanceRowActive` CSS class)
- `onRowClick` — replaces the manual `onClick` on `<tr>` elements
- `pageSize={50}` — high enough to avoid pagination for typical instance counts per app group
### 3.4 — Remove unused CSS
Remove these CSS classes from `AgentHealth.module.css` (they were only used by the raw `<table>`):
```
.instanceTable
.instanceTable thead th
.thStatus
.tdStatus
.instanceRow
.instanceRow td
.instanceRow:last-child td
.instanceRow:hover td
.instanceRowActive td
.instanceRowActive td:first-child
```
**Keep** these classes (still used by DataTable `render` functions):
```
.instanceName
.instanceMeta
.instanceError
.instanceHeartbeatStale
.instanceHeartbeatDead
```
### Visual verification checklist
After the refactor, verify at `/agents`:
- [ ] StatusDot column renders colored dots in the first column
- [ ] Instance name renders in mono bold
- [ ] State column shows Badge with correct color variant
- [ ] Uptime, TPS, Errors, Heartbeat columns show muted mono text
- [ ] Error values show in `var(--error)` red
- [ ] Stale/dead heartbeat timestamps show warning/error colors
- [ ] Row click opens the DetailPanel
- [ ] Selected row is visually highlighted
- [ ] Table sits flush inside GroupCard (no double borders)
- [ ] Alert banner still renders below the table for groups with dead instances
---
## Execution order
1. **Task 1** — LogViewer composite (no dependencies)
2. **Task 2** — Barrel exports (depends on Task 1)
3. **Task 3** — AgentHealth DataTable refactor (independent of Tasks 1-2)
Tasks 1+2 and Task 3 can be parallelized since they touch different parts of the codebase.
## Verification
```bash
# Run LogViewer tests
npx vitest run src/design-system/composites/LogViewer
# Run all tests to check nothing broke
npx vitest run
# Start dev server for visual verification
npm run dev
# Then navigate to /agents and /agents/{appId}/{instanceId}
```

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,173 @@
# Login Dialog Design Spec
## Overview
A composable login component for the Cameleer3 design system. Provides a `LoginForm` content component and a `LoginDialog` wrapper that puts it inside a Modal. Supports username/password credentials, configurable social/SSO providers, and built-in client-side validation.
## Components
### LoginForm
Core form component. Lives in `src/design-system/composites/LoginForm/`.
```tsx
interface SocialProvider {
label: string // e.g. "Continue with Google"
icon?: ReactNode // SVG icon, optional
onClick: () => void
}
interface LoginFormProps {
logo?: ReactNode
title?: string // Default: "Sign in"
socialProviders?: SocialProvider[] // Omit or [] to hide social section + divider
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void // Omit to hide credentials section
onForgotPassword?: () => void // Omit to hide link
onSignUp?: () => void // Omit to hide "Don't have an account?"
error?: string // Server-side error, rendered as Alert
loading?: boolean // Disables form, spinner on submit button
className?: string
}
```
### LoginDialog
Thin wrapper — passes all `LoginFormProps` through to `LoginForm`, adds Modal control.
```tsx
interface LoginDialogProps extends LoginFormProps {
open: boolean
onClose: () => void
}
```
Uses `Modal` with `size="sm"` (400px).
## Layout
Social-first ordering, top to bottom:
1. **Logo slot** — optional `ReactNode` rendered centered above title
2. **Title** — "Sign in" default, centered
3. **Server error**`Alert variant="error"` shown when `error` prop is set, between title and social buttons
4. **Social buttons** — stacked vertically, each is a `Button variant="secondary"` with icon + label. Hidden when `socialProviders` is empty/omitted.
5. **Divider** — horizontal rule with "or" text, centered. Hidden when social section is hidden.
6. **Email field**`FormField` + `Input`, required, placeholder "you@example.com"
7. **Password field**`FormField` + `Input type="password"`, required, placeholder "••••••••"
8. **Remember me / Forgot password row**`Checkbox` on the left, amber link on the right. Forgot password link hidden when `onForgotPassword` omitted.
9. **Submit button**`Button variant="primary"`, full width, label "Sign in"
10. **Sign up link** — "Don't have an account? Sign up" centered below. Hidden when `onSignUp` omitted.
### Configuration Variants
The form adapts automatically based on props:
- **Full** — `socialProviders` + `onSubmit` both provided. Social buttons, divider, and credentials all shown.
- **Credentials only** — `onSubmit` provided, no `socialProviders`. Social section and divider hidden.
- **Social only** — `socialProviders` provided, `onSubmit` omitted. Credentials section (email, password, remember me, submit button) and divider hidden.
## Validation
Client-side, triggered on form submit (not on blur):
| Field | Rule | Error message |
|----------|---------------------------------------------------|----------------------------------------|
| Email | Required | "Email is required" |
| Email | Basic format: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` | "Please enter a valid email address" |
| Password | Required | "Password is required" |
| Password | Minimum 8 characters | "Password must be at least 8 characters" |
- `onSubmit` only fires when all validation passes
- Field errors displayed inline below each input using `FormField` error pattern (red border + message)
- Field errors clear when the user starts typing in that field
- Server `error` prop clears automatically on next submit attempt
## States
### Loading
When `loading={true}`:
- All inputs disabled
- All social buttons disabled
- Submit button shows `Spinner` component, text hidden (matches existing `Button loading` pattern)
- Form cannot be submitted
### Error
- Server error: `Alert variant="error"` rendered between title and social buttons
- Field errors: inline below each input via `FormField` error styling (red border, error text)
## Styling
- CSS Modules: `LoginForm.module.css`
- All colors via CSS custom properties from `tokens.css`
- Dark mode works automatically — no extra overrides needed
- Social buttons: `var(--bg-surface)` background, `var(--border)` border, hover uses `var(--bg-hover)`
- Divider: `var(--border)` line, `var(--text-muted)` "or" text
- Forgot password + Sign up links: `var(--amber)` color, `font-weight: 500`
- Form gap: 14px between fields
- Social button gap: 8px between buttons
## Accessibility
- `<form>` element with `aria-label="Sign in"`
- Labels tied to inputs via `htmlFor`/`id`
- Error messages linked with `aria-describedby`
- First input auto-focused on mount
- `LoginDialog` traps focus via Modal
- Social buttons are `<button>` elements, keyboard-navigable
- Alert uses `role="alert"` for screen readers
- Enter key submits form (standard `<form onSubmit>`)
## File Structure
```
src/design-system/composites/LoginForm/
LoginForm.tsx
LoginForm.module.css
LoginForm.test.tsx
LoginDialog.tsx
LoginDialog.test.tsx
```
Exports added to `src/design-system/composites/index.ts`.
## Primitives Reused
- `FormField` — label + error display wrapper
- `Input` — email and password fields
- `Checkbox` — remember me
- `Button` — submit (primary) + social buttons (secondary)
- `Alert` — server error display
- `Spinner` — loading state in submit button
- `Modal` — LoginDialog wrapper
## Testing
Tests with Vitest + React Testing Library, wrapped in `ThemeProvider`.
### LoginForm tests:
- Renders all elements when all props provided
- Hides social section when `socialProviders` is empty
- Hides divider when no social providers
- Hides forgot password link when `onForgotPassword` omitted
- Hides sign up link when `onSignUp` omitted
- Shows server error Alert when `error` prop set
- Validates required email
- Validates email format
- Validates required password
- Validates password minimum length
- Clears field errors on typing
- Calls `onSubmit` with credentials when valid
- Does not call `onSubmit` when validation fails
- Disables form when `loading={true}`
- Shows spinner on submit button when loading
- Calls social provider `onClick` when clicked
- Calls `onForgotPassword` when link clicked
- Calls `onSignUp` when link clicked
### LoginDialog tests:
- Renders Modal with LoginForm when `open={true}`
- Does not render when `open={false}`
- Calls `onClose` on backdrop click / Esc
- Passes all LoginForm props through

View File

@@ -0,0 +1,295 @@
# Mock UI Deviations — Design Spec
## Overview
The mock pages in `src/pages/` build several UI patterns using raw CSS and inline HTML that should either be promoted into the design system or refactored to use existing components. This spec captures each deviation and its resolution to minimize rework when transitioning to the real application.
## Decision Framework
A pattern is promoted to the design system when it:
- Appears on 2+ pages with the same structure
- Is visually distinctive and would be inconsistent if reimplemented
- Will be needed by the real application
A pattern stays in the pages when it is page-specific composition or a one-off layout.
---
## 1. KpiStrip — New Composite
**Problem:** Dashboard, Routes, and AgentHealth each build a custom KPI header strip (~320 lines of duplicated layout code). Same visual structure: horizontal row of cards with colored left border, uppercase label, large value, trend indicator, subtitle, and optional sparkline.
**Solution:** New composite `KpiStrip`.
```tsx
interface KpiItem {
label: string
value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string // CSS token, e.g. "var(--success)"
}
interface KpiStripProps {
items: KpiItem[]
className?: string
}
```
**Layout:**
- Horizontal flex row with equal-width cards
- Each card: 3px left border (colored via `borderColor`, default `var(--amber)`), padding 16px 20px
- Card surface: `var(--bg-surface)`, border: `var(--border-subtle)`, radius: `var(--radius-md)`
- Label: 11px uppercase, monospace weight 500, `var(--text-muted)`
- Value: 28px, weight 700, `var(--text-primary)`
- Trend: inline next to value, 11px. Color controlled by `trend.variant` (maps to semantic tokens). Default `'muted'`. The caller decides what color a trend should be — "↑ +12%" on error count is `'error'`, on throughput is `'success'`.
- Subtitle: 11px, `var(--text-secondary)`
- Sparkline: existing `Sparkline` primitive rendered top-right of card
**Note:** KpiStrip builds its own card-like containers internally. It does NOT reuse the `Card` primitive because `Card` uses a top accent border while KpiStrip needs a left border. The visual surface (bg, border, radius, shadow) uses the same tokens but the layout is distinct.
**File location:** `src/design-system/composites/KpiStrip/`
**Pages to refactor:** Dashboard.tsx, Routes.tsx, AgentHealth.tsx — replace inline `KpiHeader` functions with `<KpiStrip items={[...]} />`.
---
## 2. SplitPane — New Composite
**Problem:** Admin RBAC tabs (UsersTab, GroupsTab, RolesTab) each build a custom CSS grid split-pane layout with scrollable list, detail panel, and empty state placeholder.
**Solution:** New composite `SplitPane`.
```tsx
interface SplitPaneProps {
list: ReactNode
detail: ReactNode | null // null renders empty state
emptyMessage?: string // Default: "Select an item to view details"
ratio?: '1:1' | '1:2' | '2:3' // Default: '1:2'
className?: string
}
```
**Layout:**
- CSS grid with two columns at the specified ratio
- Left panel: scrollable, `var(--bg-surface)` background, right border `var(--border-subtle)`
- Right panel: scrollable, `var(--bg-raised)` background
- Empty state: centered text, `var(--text-muted)`, italic
- Both panels fill available height (the parent controls the overall height)
**File location:** `src/design-system/composites/SplitPane/`
**Pages to refactor:** UsersTab.tsx, GroupsTab.tsx, RolesTab.tsx — replace custom grid CSS with `<SplitPane>`.
---
## 2b. EntityList — New Composite
**Problem:** The left-side list panels in UsersTab, GroupsTab, and RolesTab all build the same frame: a search input + "Add" button header, a scrollable list of items (avatar + text + badges), and selection highlighting. Each tab re-implements this frame with ~50 lines of identical structure.
**Solution:** New composite `EntityList`.
```tsx
interface EntityListProps<T> {
items: T[]
renderItem: (item: T, isSelected: boolean) => ReactNode
getItemId: (item: T) => string
selectedId?: string
onSelect?: (id: string) => void
searchPlaceholder?: string // Default: "Search..."
onSearch?: (query: string) => void
addLabel?: string // e.g. "+ Add user" — omit to hide button
onAdd?: () => void
emptyMessage?: string // Default: "No items found"
className?: string
}
```
**Layout:**
- Header row: `Input` (search, with icon) on the left, `Button variant="secondary" size="sm"` (add) on the right. Header hidden when both `onSearch` and `onAdd` are omitted.
- Scrollable list below header, `var(--bg-surface)` background
- Each item: clickable row with `var(--bg-hover)` on hover, `var(--amber-bg)` + left amber border when selected
- Items rendered via `renderItem` — the component provides the clickable row wrapper, the caller provides the content
- `role="listbox"` on the list, `role="option"` on each item for accessibility
- Empty state: centered `emptyMessage` text when `items` is empty
**Typical item content (provided by caller via `renderItem`):**
- Avatar + name + subtitle + badge tags — but this is not prescribed by EntityList. The component is agnostic about item content.
**Combined usage with SplitPane:**
```tsx
<SplitPane
list={
<EntityList
items={filteredUsers}
renderItem={(user, isSelected) => (
<>
<Avatar name={user.name} size="sm" />
<div>
<div>{user.name}</div>
<div>{user.email}</div>
<div>{user.roles.map(r => <Badge key={r} label={r} />)}</div>
</div>
</>
)}
getItemId={(u) => u.id}
selectedId={selectedId}
onSelect={setSelectedId}
searchPlaceholder="Search users..."
onSearch={setSearchQuery}
addLabel="+ Add user"
onAdd={() => setAddDialogOpen(true)}
/>
}
detail={selectedUser ? <UserDetail user={selectedUser} /> : null}
/>
```
**File location:** `src/design-system/composites/EntityList/`
**Pages to refactor:** UsersTab.tsx, GroupsTab.tsx, RolesTab.tsx — replace custom list rendering with `<EntityList>`. Combined with SplitPane, each tab reduces from ~200 lines to ~50 lines of domain-specific render logic.
---
## 3. Refactor AgentHealth Instance Table to DataTable
**Problem:** AgentHealth builds instance tables using raw HTML `<table>` elements instead of the existing `DataTable` composite.
**Solution:** Refactor to use `DataTable` with column definitions and custom cell renderers. No design system changes needed.
**Refactor scope:**
- Replace `<table>` blocks in AgentHealth.tsx (~60 lines) with `<DataTable>` using `flush` prop
- Define columns with `render` functions for State (Badge) and StatusDot columns
- Remove associated table CSS from AgentHealth.module.css
---
## 4. LogViewer — New Composite
**Problem:** AgentInstance renders log entries as custom HTML with inline styling — timestamped lines with severity levels in monospace.
**Solution:** New composite `LogViewer`.
```tsx
interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string // Default: 400
className?: string
}
```
**Layout:**
- Scrollable container with `max-height`, `var(--bg-inset)` background, `var(--radius-md)` border-radius
- Each line: flex row with timestamp (muted, monospace, 11px) + level badge + message (monospace, 12px)
- Level badge colors: info=`var(--running)`, warn=`var(--warning)`, error=`var(--error)`, debug=`var(--text-muted)`
- Level badge: uppercase, 9px, `var(--font-mono)`, pill-shaped with tinted background
- Auto-scroll to bottom on new entries; pauses when user scrolls up; resumes on scroll-to-bottom
**File location:** `src/design-system/composites/LogViewer/`
**Pages to refactor:** AgentInstance.tsx — replace custom log rendering with `<LogViewer entries={logs} />`.
---
## 5. StatusText — New Primitive
**Problem:** Dashboard and Routes use inline `style={{ color: 'var(--error)', fontWeight: 600 }}` for status values like "BREACH", "OK", colored percentages.
**Solution:** New primitive `StatusText`.
```tsx
interface StatusTextProps {
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
bold?: boolean // Default: false
children: ReactNode
className?: string
}
```
**Styling:**
- Inline `<span>` element
- Color mapped to semantic tokens: success=`var(--success)`, warning=`var(--warning)`, error=`var(--error)`, running=`var(--running)`, muted=`var(--text-muted)`
- `bold` adds `font-weight: 600`
- Inherits font-size from parent
**File location:** `src/design-system/primitives/StatusText/`
**Pages to refactor:** Dashboard.tsx, Routes.tsx — replace inline style attributes with `<StatusText>`.
---
## 6. Card Title Extension
**Problem:** Routes page wraps charts in custom divs with uppercase titles. The existing `Card` component has no title support.
**Solution:** Add optional `title` prop to existing `Card` primitive.
```tsx
interface CardProps {
children: ReactNode
accent?: string // Existing
title?: string // NEW
className?: string // Existing
}
```
**When `title` is provided:**
- Renders a header div inside the card, above children
- Title: 11px uppercase, `var(--font-mono)`, weight 600, `var(--text-secondary)`, letter-spacing 0.5px
- Separated from content by 1px `var(--border-subtle)` bottom border and 12px padding-bottom
- Content area gets 16px padding-top
**File location:** Modify existing `src/design-system/primitives/Card/Card.tsx`
**Pages to refactor:** Routes.tsx — replace custom chart wrapper divs with `<Card title="Throughput (msg/s)">`.
---
## Implementation Priority
1. **KpiStrip** — highest impact, 3 pages, ~320 lines eliminated
2. **StatusText** — smallest scope, quick win, unblocks cleaner page code
3. **Card title** — small change to existing component, unblocks Routes cleanup
4. **SplitPane + EntityList** — 3 admin tabs, clean pattern. Build together since EntityList is the natural content for SplitPane's list slot.
5. **LogViewer** — 1 page but important for real app
6. **AgentHealth DataTable refactor** — pure page cleanup, no DS changes
## Testing
All new components tested with Vitest + React Testing Library, co-located test files. Page refactors verified by running existing tests + visual check that pages look identical before and after.
## Barrel Exports
New components added to respective barrel exports:
- `src/design-system/primitives/index.ts` — StatusText
- `src/design-system/composites/index.ts` — KpiStrip, SplitPane, EntityList, LogViewer
## Documentation Updates
### COMPONENT_GUIDE.md
Add entries for each new component to the appropriate decision trees:
- **Data Display section:** Add KpiStrip — "Use KpiStrip for a row of summary metrics at the top of a page (exchanges, error rate, latency, etc.)"
- **Data Display section:** Add LogViewer — "Use LogViewer for scrollable log output with timestamped, severity-colored entries"
- **Layout section:** Add SplitPane — "Use SplitPane for master/detail layouts: selectable list on the left, detail view on the right"
- **Data Display section:** Add EntityList — "Use EntityList for searchable, selectable lists of entities (users, groups, roles, etc.). Combine with SplitPane for CRUD management screens."
- **Text & Labels section:** Add StatusText — "Use StatusText for inline colored status values (success rates, SLA status, trend indicators). Use StatusDot for colored dot indicators."
- **Card section:** Document new `title` prop — "Pass `title` to Card for a titled content container (e.g., chart cards). Title renders as an uppercase header with separator."
### Inventory Page
Add demos for each new component to `src/pages/Inventory/sections/`:
- **CompositesSection.tsx:** Add KpiStrip, SplitPane, EntityList, LogViewer demos with realistic sample data
- **PrimitivesSection.tsx:** Add StatusText demo showing all variants
- **Card demo:** Update existing Card demo to show the `title` prop variant
Each demo follows the existing DemoCard pattern with `id` anchors, and nav entries are added to `Inventory.tsx`.

View File

@@ -1,6 +1,6 @@
{
"name": "@cameleer/design-system",
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
@@ -12,8 +12,12 @@
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"files": [
"dist"
],
"sideEffects": [
"*.css"
],
"publishConfig": {
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
},
@@ -27,7 +31,8 @@
"build:lib": "vite build --config vite.lib.config.ts",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest"
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^19.0.0",
@@ -40,6 +45,7 @@
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,8 @@ export function DataTable<T extends { id: string }>({
pageSizeOptions = [10, 25, 50, 100],
rowAccent,
expandedContent,
flush = false,
onSortChange,
}: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
@@ -30,14 +32,16 @@ export function DataTable<T extends { id: string }>({
const [pageSize, setPageSize] = useState(initialPageSize)
const [expandedId, setExpandedId] = useState<string | null>(null)
// When onSortChange is provided (controlled mode), skip client-side sorting
const sorted = useMemo(() => {
if (onSortChange) return data
if (!sortKey) return data
return [...data].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey]
const bv = (b as Record<string, unknown>)[sortKey]
return compareValues(av, bv, sortDir)
})
}, [data, sortKey, sortDir])
}, [data, sortKey, sortDir, onSortChange])
const totalRows = sorted.length
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
@@ -51,13 +55,17 @@ export function DataTable<T extends { id: string }>({
function handleHeaderClick(col: Column<T>) {
if (!sortable && !col.sortable) return
let newDir: SortDir
if (sortKey === col.key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
newDir = sortDir === 'asc' ? 'desc' : 'asc'
setSortDir(newDir)
} else {
newDir = 'asc'
setSortKey(col.key)
setSortDir('asc')
setSortDir(newDir)
}
setPage(1)
onSortChange?.(col.key, newDir)
}
function handleRowClick(row: T) {
@@ -73,7 +81,7 @@ export function DataTable<T extends { id: string }>({
}))
return (
<div className={styles.wrapper}>
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
<div className={styles.scroll}>
<table className={styles.table}>
<thead>

View File

@@ -18,4 +18,10 @@ export interface DataTableProps<T extends { id: string }> {
pageSizeOptions?: number[]
rowAccent?: (row: T) => 'error' | 'warning' | undefined
expandedContent?: (row: T) => ReactNode | null
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
flush?: boolean
/** Controlled sort: called when the user clicks a sortable column header.
* When provided, the component skips client-side sorting — the caller is
* responsible for providing `data` in the desired order. */
onSortChange?: (key: string, dir: 'asc' | 'desc') => void
}

View File

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

View File

@@ -0,0 +1,49 @@
.entityListRoot {
display: flex;
flex-direction: column;
height: 100%;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeaderSearch {
flex: 1;
}
.list {
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(--amber-bg);
border-left: 3px solid var(--amber);
}
.emptyMessage {
padding: 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EntityList } from './EntityList'
interface TestItem {
id: string
name: string
}
const items: TestItem[] = [
{ id: '1', name: 'Alpha' },
{ id: '2', name: 'Beta' },
{ id: '3', name: 'Gamma' },
]
describe('EntityList', () => {
it('renders all items', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
/>
)
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('calls onSelect when item clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSelect={onSelect}
/>
)
await user.click(screen.getByText('Beta'))
expect(onSelect).toHaveBeenCalledWith('2')
})
it('highlights selected item (aria-selected="true" and has selected class)', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
selectedId="2"
/>
)
const selectedOption = screen.getByText('Beta').closest('[role="option"]')
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
const unselectedOption = screen.getByText('Alpha').closest('[role="option"]')
expect(unselectedOption).toHaveAttribute('aria-selected', 'false')
})
it('renders search input when onSearch provided', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSearch={() => {}}
searchPlaceholder="Filter items..."
/>
)
expect(screen.getByPlaceholderText('Filter items...')).toBeInTheDocument()
})
it('calls onSearch when typing in search', async () => {
const onSearch = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onSearch={onSearch}
/>
)
const input = screen.getByPlaceholderText('Search...')
await user.type(input, 'test')
expect(onSearch).toHaveBeenLastCalledWith('test')
})
it('renders add button when onAdd provided', () => {
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onAdd={() => {}}
addLabel="Add Item"
/>
)
expect(screen.getByText('Add Item')).toBeInTheDocument()
})
it('calls onAdd when add button clicked', async () => {
const onAdd = vi.fn()
const user = userEvent.setup()
render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
onAdd={onAdd}
addLabel="Add Item"
/>
)
await user.click(screen.getByText('Add Item'))
expect(onAdd).toHaveBeenCalledOnce()
})
it('hides header when no search or add', () => {
const { container } = render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
/>
)
// No input or button should be present in the header area
expect(container.querySelector('input')).toBeNull()
expect(container.querySelector('button')).toBeNull()
})
it('shows empty message when items is empty', () => {
render(
<EntityList
items={[]}
renderItem={(item: TestItem) => <span>{item.name}</span>}
getItemId={(item: TestItem) => item.id}
/>
)
expect(screen.getByText('No items found')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<EntityList
items={[]}
renderItem={(item: TestItem) => <span>{item.name}</span>}
getItemId={(item: TestItem) => item.id}
emptyMessage="Nothing here"
/>
)
expect(screen.getByText('Nothing here')).toBeInTheDocument()
})
it('accepts className', () => {
const { container } = render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,97 @@
import { useState, type ReactNode } from 'react'
import { Input } from '../../primitives/Input/Input'
import { Button } from '../../primitives/Button/Button'
import styles from './EntityList.module.css'
interface EntityListProps<T> {
items: T[]
renderItem: (item: T, isSelected: boolean) => ReactNode
getItemId: (item: T) => string
selectedId?: string
onSelect?: (id: string) => void
searchPlaceholder?: string
onSearch?: (query: string) => void
addLabel?: string
onAdd?: () => void
emptyMessage?: string
className?: string
}
export function EntityList<T>({
items,
renderItem,
getItemId,
selectedId,
onSelect,
searchPlaceholder = 'Search...',
onSearch,
addLabel,
onAdd,
emptyMessage = 'No items found',
className,
}: EntityListProps<T>) {
const [searchValue, setSearchValue] = useState('')
const showHeader = !!onSearch || !!onAdd
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setSearchValue(value)
onSearch?.(value)
}
function handleSearchClear() {
setSearchValue('')
onSearch?.('')
}
return (
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
{showHeader && (
<div className={styles.listHeader}>
{onSearch && (
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={handleSearchChange}
onClear={handleSearchClear}
className={styles.listHeaderSearch}
/>
)}
{onAdd && addLabel && (
<Button size="sm" variant="secondary" onClick={onAdd}>
{addLabel}
</Button>
)}
</div>
)}
<div className={styles.list} role="listbox">
{items.map((item) => {
const id = getItemId(item)
const isSelected = id === selectedId
return (
<div
key={id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => onSelect?.(id)}
role="option"
tabIndex={0}
aria-selected={isSelected}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect?.(id)
}
}}
>
{renderItem(item, isSelected)}
</div>
)
})}
{items.length === 0 && (
<div className={styles.emptyMessage}>{emptyMessage}</div>
)}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,79 @@
.kpiStrip {
display: grid;
gap: 12px;
margin-bottom: 20px;
}
.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;
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
}
.label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 6px;
}
.valueRow {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
}
.value {
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--text-primary);
}
.trend {
font-family: var(--font-mono);
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.trendSuccess { color: var(--success); }
.trendWarning { color: var(--warning); }
.trendError { color: var(--error); }
.trendMuted { color: var(--text-muted); }
.subtitle {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.sparkline {
margin-top: 8px;
height: 32px;
}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { KpiStrip } from './KpiStrip'
import type { KpiItem } from './KpiStrip'
const sampleItems: KpiItem[] = [
{ label: 'Total', value: 42 },
{ label: 'Active', value: '18', trend: { label: '+3', variant: 'success' } },
{ label: 'Errors', value: 5, subtitle: 'last 24h', sparkline: [1, 3, 2, 5, 4] },
]
describe('KpiStrip', () => {
it('renders all items', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
const cards = container.querySelectorAll('[class*="kpiCard"]')
expect(cards).toHaveLength(3)
})
it('renders labels and values', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('Total')).toBeInTheDocument()
expect(screen.getByText('42')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
expect(screen.getByText('18')).toBeInTheDocument()
})
it('renders trend with correct text', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('+3')).toBeInTheDocument()
})
it('applies variant class to trend (trendSuccess)', () => {
render(<KpiStrip items={sampleItems} />)
const trend = screen.getByText('+3')
expect(trend.className).toContain('trendSuccess')
})
it('hides trend when omitted', () => {
render(<KpiStrip items={[{ label: 'No Trend', value: 10 }]} />)
const { container } = render(<KpiStrip items={[{ label: 'No Trend2', value: 10 }]} />)
const trends = container.querySelectorAll('[class*="trend"]')
expect(trends).toHaveLength(0)
})
it('renders subtitle', () => {
render(<KpiStrip items={sampleItems} />)
expect(screen.getByText('last 24h')).toBeInTheDocument()
})
it('renders sparkline when data provided', () => {
const { container } = render(<KpiStrip items={sampleItems} />)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThanOrEqual(1)
})
it('accepts className prop', () => {
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
expect(container.firstChild).toHaveClass('custom')
})
it('handles empty items array', () => {
const { container } = render(<KpiStrip items={[]} />)
const cards = container.querySelectorAll('[class*="kpiCard"]')
expect(cards).toHaveLength(0)
})
it('uses default border color (--amber) when borderColor omitted', () => {
const { container } = render(<KpiStrip items={[{ label: 'Default', value: 1 }]} />)
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--amber)')
})
it('applies custom borderColor', () => {
const items: KpiItem[] = [{ label: 'Custom', value: 1, borderColor: 'var(--teal)' }]
const { container } = render(<KpiStrip items={items} />)
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--teal)')
})
it('renders trend with muted variant by default', () => {
const items: KpiItem[] = [{ label: 'Muted', value: 1, trend: { label: '0%' } }]
render(<KpiStrip items={items} />)
const trend = screen.getByText('0%')
expect(trend.className).toContain('trendMuted')
})
})

View File

@@ -0,0 +1,71 @@
import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties } from 'react'
export interface KpiItem {
label: string
value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string
sparkline?: number[]
borderColor?: string
}
export interface KpiStripProps {
items: KpiItem[]
className?: string
}
const trendClassMap: Record<string, string> = {
success: styles.trendSuccess,
warning: styles.trendWarning,
error: styles.trendError,
muted: styles.trendMuted,
}
export function KpiStrip({ items, className }: KpiStripProps) {
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
const gridStyle: CSSProperties = {
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
}
return (
<div className={stripClasses} style={gridStyle}>
{items.map((item) => {
const borderColor = item.borderColor ?? 'var(--amber)'
const cardStyle: CSSProperties & Record<string, string> = {
'--kpi-border-color': borderColor,
}
const trendVariant = item.trend?.variant ?? 'muted'
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
return (
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
<div className={styles.label}>{item.label}</div>
<div className={styles.valueRow}>
<span className={styles.value}>{item.value}</span>
{item.trend && (
<span className={`${styles.trend} ${trendClass}`}>
{item.trend.label}
</span>
)}
</div>
{item.subtitle && (
<div className={styles.subtitle}>{item.subtitle}</div>
)}
{item.sparkline && item.sparkline.length >= 2 && (
<div className={styles.sparkline}>
<Sparkline
data={item.sparkline}
color={borderColor}
width={200}
height={32}
/>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,75 @@
.container {
overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0;
font-family: var(--font-mono);
}
.line {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 12px;
line-height: 1.5;
}
.line:hover {
background: var(--bg-hover);
}
.timestamp {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
min-width: 56px;
}
.levelBadge {
flex-shrink: 0;
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 1px 6px;
border-radius: 9999px;
line-height: 1.5;
white-space: nowrap;
}
.levelInfo {
color: var(--running);
background: color-mix(in srgb, var(--running) 12%, transparent);
}
.levelWarn {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.levelError {
color: var(--error);
background: color-mix(in srgb, var(--error) 12%, transparent);
}
.levelDebug {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
.message {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
word-break: break-word;
line-height: 1.5;
}
.empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LogViewer, type LogEntry } from './LogViewer'
const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' },
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
]
describe('LogViewer', () => {
it('renders entries with timestamps and messages', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('Server started')).toBeInTheDocument()
expect(screen.getByText('High memory usage')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument()
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
})
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument()
})
it('renders with custom maxHeight (number)', () => {
const { container } = render(<LogViewer entries={entries} maxHeight={300} />)
const el = container.firstElementChild as HTMLElement
expect(el.style.maxHeight).toBe('300px')
})
it('renders with string maxHeight', () => {
const { container } = render(<LogViewer entries={entries} maxHeight="50vh" />)
const el = container.firstElementChild as HTMLElement
expect(el.style.maxHeight).toBe('50vh')
})
it('handles empty entries', () => {
render(<LogViewer entries={[]} />)
expect(screen.getByText('No log entries.')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<LogViewer entries={entries} className="custom-class" />)
const el = container.firstElementChild as HTMLElement
expect(el.classList.contains('custom-class')).toBe(true)
})
it('has role="log" for accessibility', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByRole('log')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,77 @@
import { useRef, useEffect, useCallback } from 'react'
import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string
className?: string
}
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
info: styles.levelInfo,
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
} catch {
return iso
}
}
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
}, [])
useEffect(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) {
el.scrollTop = el.scrollHeight
}
}, [entries])
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
return (
<div
ref={scrollRef}
className={[styles.container, className].filter(Boolean).join(' ')}
style={{ maxHeight: heightStyle }}
onScroll={handleScroll}
role="log"
>
{entries.map((entry, i) => (
<div key={i} className={styles.line}>
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
{entry.level.toUpperCase()}
</span>
<span className={styles.message}>{entry.message}</span>
</div>
))}
{entries.length === 0 && (
<div className={styles.empty}>No log entries.</div>
)}
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginDialog } from './LoginDialog'
const defaultProps = {
open: true,
onClose: vi.fn(),
onSubmit: vi.fn(),
}
describe('LoginDialog', () => {
it('renders Modal with LoginForm when open', () => {
render(<LoginDialog {...defaultProps} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<LoginDialog {...defaultProps} open={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('calls onClose on Esc', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<LoginDialog {...defaultProps} onClose={onClose} />)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on backdrop click', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<LoginDialog {...defaultProps} onClose={onClose} />)
await user.click(screen.getByTestId('modal-backdrop'))
expect(onClose).toHaveBeenCalled()
})
it('passes LoginForm props through', () => {
render(
<LoginDialog
{...defaultProps}
title="Welcome"
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
error="Bad credentials"
/>,
)
expect(screen.getByText('Welcome')).toBeInTheDocument()
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,15 @@
import { Modal } from '../Modal/Modal'
import { LoginForm, type LoginFormProps } from './LoginForm'
export interface LoginDialogProps extends LoginFormProps {
open: boolean
onClose: () => void
}
export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
return (
<Modal open={open} onClose={onClose} size="sm" className={className}>
<LoginForm {...formProps} />
</Modal>
)
}

View File

@@ -0,0 +1,111 @@
.loginForm {
display: flex;
flex-direction: column;
align-items: center;
font-family: var(--font-body);
width: 100%;
}
.logo {
margin-bottom: 8px;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px;
}
.error {
width: 100%;
margin-bottom: 16px;
}
.socialSection {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-bottom: 20px;
}
.socialButton {
width: 100%;
justify-content: center;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-bottom: 20px;
}
.dividerLine {
flex: 1;
height: 1px;
background: var(--border);
}
.dividerText {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.fields {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
.rememberRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.forgotLink {
font-size: 11px;
color: var(--amber);
font-weight: 500;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--font-body);
}
.forgotLink:hover {
text-decoration: underline;
}
.submitButton {
width: 100%;
}
.signUpText {
text-align: center;
font-size: 12px;
color: var(--text-secondary);
}
.signUpLink {
color: var(--amber);
font-weight: 500;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--font-body);
font-size: 12px;
}
.signUpLink:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
const socialProviders = [
{ label: 'Continue with Google', onClick: vi.fn() },
{ label: 'Continue with GitHub', onClick: vi.fn() },
]
const allProps = {
logo: <div data-testid="logo">Logo</div>,
title: 'Welcome back',
socialProviders,
onSubmit: vi.fn(),
onForgotPassword: vi.fn(),
onSignUp: vi.fn(),
}
describe('LoginForm', () => {
describe('rendering', () => {
it('renders all elements when all props provided', () => {
render(<LoginForm {...allProps} />)
expect(screen.getByTestId('logo')).toBeInTheDocument()
expect(screen.getByText('Welcome back')).toBeInTheDocument()
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
expect(screen.getByText('or')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
})
it('renders default title when title prop omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
})
it('hides social section when socialProviders is empty', () => {
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
expect(screen.queryByText('or')).not.toBeInTheDocument()
})
it('hides social section when socialProviders is omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText('or')).not.toBeInTheDocument()
})
it('hides forgot password link when onForgotPassword omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
})
it('hides sign up link when onSignUp omitted', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
})
it('hides credentials section when onSubmit omitted (social only)', () => {
render(<LoginForm socialProviders={socialProviders} />)
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
expect(screen.queryByText('or')).not.toBeInTheDocument()
// Social buttons should still render
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
})
it('shows server error Alert when error prop set', () => {
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
})
})
describe('validation', () => {
it('validates required email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
})
it('validates email format', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'notanemail')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
})
it('validates required password', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Password is required')).toBeInTheDocument()
})
it('validates password minimum length', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'short')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
})
it('clears field errors on typing', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
await user.type(screen.getByLabelText(/email/i), 't')
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
})
it('calls onSubmit with credentials when valid', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByLabelText(/remember me/i))
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
remember: true,
})
})
it('does not call onSubmit when validation fails', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={onSubmit} />)
await user.click(screen.getByRole('button', { name: 'Sign in' }))
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('loading state', () => {
it('disables form inputs when loading', () => {
render(<LoginForm {...allProps} loading />)
expect(screen.getByLabelText(/email/i)).toBeDisabled()
expect(screen.getByLabelText(/password/i)).toBeDisabled()
expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
})
it('shows spinner on submit button when loading', () => {
render(<LoginForm {...allProps} loading />)
const submitBtn = screen.getByRole('button', { name: /sign in/i })
expect(submitBtn).toBeDisabled()
// Button component renders Spinner when loading=true
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
})
it('disables social buttons when loading', () => {
render(<LoginForm {...allProps} loading />)
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
})
})
describe('callbacks', () => {
it('calls social provider onClick when clicked', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
expect(onClick).toHaveBeenCalledOnce()
})
it('calls onForgotPassword when link clicked', async () => {
const onForgotPassword = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
await user.click(screen.getByText(/forgot password/i))
expect(onForgotPassword).toHaveBeenCalledOnce()
})
it('calls onSignUp when link clicked', async () => {
const onSignUp = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
await user.click(screen.getByText(/sign up/i))
expect(onSignUp).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,223 @@
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
import { Button } from '../../primitives/Button/Button'
import { Input } from '../../primitives/Input/Input'
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
import { FormField } from '../../primitives/FormField/FormField'
import { Alert } from '../../primitives/Alert/Alert'
import styles from './LoginForm.module.css'
export interface SocialProvider {
label: string
icon?: ReactNode
onClick: () => void
}
export interface LoginFormProps {
logo?: ReactNode
title?: string
socialProviders?: SocialProvider[]
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
onForgotPassword?: () => void
onSignUp?: () => void
error?: string
loading?: boolean
className?: string
}
interface FieldErrors {
email?: string
password?: string
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function validate(email: string, password: string): FieldErrors {
const errors: FieldErrors = {}
if (!email) {
errors.email = 'Email is required'
} else if (!EMAIL_REGEX.test(email)) {
errors.email = 'Please enter a valid email address'
}
if (!password) {
errors.password = 'Password is required'
} else if (password.length < 8) {
errors.password = 'Password must be at least 8 characters'
}
return errors
}
export function LoginForm({
logo,
title = 'Sign in',
socialProviders,
onSubmit,
onForgotPassword,
onSignUp,
error,
loading = false,
className,
}: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [submitted, setSubmitted] = useState(false)
const emailRef = useRef<HTMLInputElement>(null)
// Auto-focus first input on mount
useEffect(() => {
emailRef.current?.focus()
}, [])
// Reset submitted flag when error prop changes (new server error from re-attempt)
useEffect(() => {
if (error) setSubmitted(false)
}, [error])
// Server error is shown from prop, hidden after next submit attempt
const showServerError = error && !submitted
const hasSocial = socialProviders && socialProviders.length > 0
const hasCredentials = !!onSubmit
const showDivider = hasSocial && hasCredentials
function handleSubmit(e: FormEvent) {
e.preventDefault()
setSubmitted(true)
const errors = validate(email, password)
setFieldErrors(errors)
if (Object.keys(errors).length === 0) {
onSubmit?.({ email, password, remember })
}
}
return (
<div className={`${styles.loginForm} ${className ?? ''}`}>
{logo && <div className={styles.logo}>{logo}</div>}
<h2 className={styles.title}>{title}</h2>
{showServerError && (
<div className={styles.error}>
<Alert variant="error">{error}</Alert>
</div>
)}
{hasSocial && (
<div className={styles.socialSection}>
{socialProviders.map((provider) => (
<Button
key={provider.label}
variant="secondary"
className={styles.socialButton}
onClick={provider.onClick}
disabled={loading}
type="button"
>
{provider.icon}
{provider.label}
</Button>
))}
</div>
)}
{showDivider && (
<div className={styles.divider}>
<div className={styles.dividerLine} />
<span className={styles.dividerText}>or</span>
<div className={styles.dividerLine} />
</div>
)}
{hasCredentials && (
<form
className={styles.fields}
onSubmit={handleSubmit}
aria-label="Sign in"
noValidate
>
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
<Input
ref={emailRef}
id="login-email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
}}
disabled={loading}
/>
</FormField>
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
<Input
id="login-password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
}}
disabled={loading}
/>
</FormField>
<div className={styles.rememberRow}>
<Checkbox
label="Remember me"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={loading}
/>
{onForgotPassword && (
<button
type="button"
className={styles.forgotLink}
onClick={onForgotPassword}
>
Forgot password?
</button>
)}
</div>
<Button
variant="primary"
type="submit"
loading={loading}
className={styles.submitButton}
>
Sign in
</Button>
{onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</form>
)}
{!hasCredentials && onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</div>
)
}

View File

@@ -16,12 +16,12 @@
.item:hover {
background: var(--sidebar-hover);
color: #E8DFD4;
color: var(--sidebar-text);
}
.item.active {
background: var(--sidebar-active);
color: var(--amber-light);
color: var(--amber);
border-left-color: var(--amber);
}
@@ -69,5 +69,5 @@
.item.active .count {
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 {
background: rgba(61, 124, 71, 0.5);
background: color-mix(in srgb, var(--success) 50%, transparent);
}
.slow {
background: rgba(194, 117, 22, 0.5);
background: color-mix(in srgb, var(--warning) 50%, transparent);
}
.fail {
background: rgba(192, 57, 43, 0.5);
background: color-mix(in srgb, var(--error) 50%, transparent);
}
.dur {
@@ -89,6 +89,13 @@
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 {
color: var(--text-muted);
font-size: 12px;

View File

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

@@ -0,0 +1,37 @@
.splitPane {
display: grid;
grid-template-columns: var(--split-columns, 1fr 2fr);
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 0;
height: 100%;
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);
overflow-y: auto;
}
.detailPane {
background: var(--bg-raised);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-faint);
font-size: 13px;
font-family: var(--font-body);
font-style: italic;
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SplitPane } from './SplitPane'
describe('SplitPane', () => {
it('renders list and detail content', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={<div>Detail content</div>}
/>
)
expect(screen.getByText('List items')).toBeInTheDocument()
expect(screen.getByText('Detail content')).toBeInTheDocument()
})
it('shows default empty message when detail is null', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={null}
/>
)
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={null}
emptyMessage="Pick something"
/>
)
expect(screen.getByText('Pick something')).toBeInTheDocument()
})
it('renders with different ratios (checks --split-columns CSS property)', () => {
const { container, rerender } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
ratio="1:1"
/>
)
const root = container.firstChild as HTMLElement
expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
rerender(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
ratio="2:3"
/>
)
expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
})
it('accepts className', () => {
const { container } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
import styles from './SplitPane.module.css'
interface SplitPaneProps {
list: ReactNode
detail: ReactNode | null
emptyMessage?: string
ratio?: '1:1' | '1:2' | '2:3'
className?: string
}
const ratioMap: Record<string, string> = {
'1:1': '1fr 1fr',
'1:2': '1fr 2fr',
'2:3': '2fr 3fr',
}
export function SplitPane({
list,
detail,
emptyMessage = 'Select an item to view details',
ratio = '1:2',
className,
}: SplitPaneProps) {
return (
<div
className={`${styles.splitPane} ${className ?? ''}`}
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
>
<div className={styles.listPane}>{list}</div>
<div className={styles.detailPane}>
{detail !== null ? detail : (
<div className={styles.emptyDetail}>{emptyMessage}</div>
)}
</div>
</div>
)
}

View File

@@ -6,21 +6,38 @@ export { BarChart } from './BarChart/BarChart'
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
export { CommandPalette } from './CommandPalette/CommandPalette'
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 type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel'
export { EntityList } from './EntityList/EntityList'
export { Dropdown } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed'
export { GroupCard } from './GroupCard/GroupCard'
export { KpiStrip } from './KpiStrip/KpiStrip'
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
export type { FeedEvent } from './EventFeed/EventFeed'
export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart'
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
export { LoginDialog } from './LoginForm/LoginDialog'
export type { LoginDialogProps } from './LoginForm/LoginDialog'
export { LoginForm } from './LoginForm/LoginForm'
export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
export { MenuItem } from './MenuItem/MenuItem'
export { Modal } from './Modal/Modal'
export { MultiSelect } from './MultiSelect/MultiSelect'
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
export { Popover } from './Popover/Popover'
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
export { RouteFlow } from './RouteFlow/RouteFlow'
export type { RouteNode } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { SplitPane } from './SplitPane/SplitPane'
export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView'

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@
flex-shrink: 0;
}
/* Center search trigger */
/* Search trigger */
.search {
display: flex;
align-items: center;
@@ -36,9 +36,9 @@
font-family: var(--font-body);
cursor: pointer;
transition: border-color 0.15s;
min-width: 180px;
flex: 1;
max-width: 280px;
width: 200px;
flex-shrink: 1;
min-width: 120px;
text-align: left;
}
@@ -81,6 +81,77 @@
flex-shrink: 0;
}
.liveToggle {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
height: 30px;
}
.liveToggle:hover {
border-color: var(--text-faint);
}
.liveToggleActive {
color: var(--success);
border-color: var(--success-border);
background: var(--success-bg);
}
.liveToggleActive:hover {
border-color: var(--success);
}
.liveDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.liveToggleActive .liveDot {
background: var(--success);
animation: livePulse 2s ease-in-out infinite;
}
@keyframes livePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.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 {
font-family: var(--font-mono);
font-size: 10px;
@@ -100,6 +171,7 @@
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.userName {

View File

@@ -1,10 +1,13 @@
import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown'
import { Avatar } from '../../primitives/Avatar/Avatar'
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters, type ExchangeStatus } from '../../providers/GlobalFilterProvider'
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider'
interface BreadcrumbItem {
label: string
@@ -15,47 +18,34 @@ interface TopBarProps {
breadcrumb: BreadcrumbItem[]
environment?: string
user?: { name: string }
onLogout?: () => void
className?: string
}
const STATUS_PILLS: { status: ExchangeStatus; label: string }[] = [
{ status: 'completed', label: 'OK' },
{ status: 'warning', label: 'Warn' },
{ status: 'failed', label: 'Error' },
{ status: 'running', label: 'Running' },
const STATUS_ITEMS: ButtonGroupItem[] = [
{ value: 'completed', label: 'OK', color: 'var(--success)' },
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ value: 'failed', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]
export function TopBar({
breadcrumb,
environment,
user,
onLogout,
className,
}: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme()
return (
<header className={`${styles.topbar} ${className ?? ''}`}>
{/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
{/* Filters: time range + status pills */}
<div className={styles.filters}>
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{STATUS_PILLS.map(({ status, label }) => (
<FilterPill
key={status}
label={label}
active={globalFilters.statusFilters.has(status)}
onClick={() => globalFilters.toggleStatus(status)}
/>
))}
</div>
{/* Center: Search trigger */}
{/* Search trigger */}
<button
className={styles.search}
onClick={() => commandPalette.setOpen(true)}
@@ -72,16 +62,64 @@ export function TopBar({
<span className={styles.kbd}>Ctrl+K</span>
</button>
{/* Right: env badge, user */}
{/* Status filter group */}
<ButtonGroup
items={STATUS_ITEMS}
value={globalFilters.statusFilters}
onChange={(selected) => {
// Sync with global filter by toggling the diff
const current = globalFilters.statusFilters
for (const v of selected) {
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/>
{/* Time range pills */}
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
<div className={styles.right}>
<button
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
type="button"
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
</button>
<button
className={styles.themeToggle}
onClick={toggleTheme}
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 && (
<span className={styles.env}>{environment}</span>
)}
{user && (
<div className={styles.user}>
<span className={styles.userName}>{user.name}</span>
<Avatar name={user.name} size="md" />
</div>
<Dropdown
trigger={
<div className={styles.user}>
<span className={styles.userName}>{user.name}</span>
<Avatar name={user.name} size="md" />
</div>
}
items={[
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
]}
/>
)}
</div>
</header>

View File

@@ -20,7 +20,6 @@
}
.dashed {
background: transparent !important;
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

@@ -11,3 +11,22 @@
.accent-warning { border-top: 3px solid var(--warning); }
.accent-error { border-top: 3px solid var(--error); }
.accent-running { border-top: 3px solid var(--running); }
.titleHeader {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.titleText {
font-size: 11px;
text-transform: uppercase;
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.5px;
margin: 0;
}
.body {
padding: 16px;
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('renders children', () => {
render(<Card>Hello world</Card>)
expect(screen.getByText('Hello world')).toBeInTheDocument()
})
it('renders title when provided', () => {
render(<Card title="Status">Content</Card>)
expect(screen.getByText('Status')).toBeInTheDocument()
})
it('does not render title header when title is omitted', () => {
const { container } = render(<Card>Content</Card>)
expect(container.querySelector('h3')).toBeNull()
})
it('wraps children in body div when title is provided', () => {
render(<Card title="Status"><span>Content</span></Card>)
const content = screen.getByText('Content')
expect(content.parentElement).toHaveClass('body')
})
it('renders with accent and title together', () => {
const { container } = render(
<Card accent="success" title="Health">Content</Card>,
)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('accent-success')
expect(screen.getByText('Health')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<Card className="custom">Content</Card>)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('custom')
})
it('renders children directly when no title (no wrapper div)', () => {
const { container } = render(<Card><span>Direct child</span></Card>)
const card = container.firstChild as HTMLElement
const span = screen.getByText('Direct child')
expect(span.parentElement).toBe(card)
})
})

View File

@@ -4,15 +4,25 @@ import type { ReactNode } from 'react'
interface CardProps {
children: ReactNode
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
title?: string
className?: string
}
export function Card({ children, accent = 'none', className }: CardProps) {
export function Card({ children, accent = 'none', title, className }: CardProps) {
const classes = [
styles.card,
accent !== 'none' ? styles[`accent-${accent}`] : '',
className ?? '',
].filter(Boolean).join(' ')
return <div className={classes}>{children}</div>
return (
<div className={classes}>
{title && (
<div className={styles.titleHeader}>
<h3 className={styles.titleText}>{title}</h3>
</div>
)}
{title ? <div className={styles.body}>{children}</div> : children}
</div>
)
}

View File

@@ -4,15 +4,17 @@ import userEvent from '@testing-library/user-event'
import { DateRangePicker } from './DateRangePicker'
describe('DateRangePicker', () => {
it('renders two datetime inputs', () => {
const { container } = render(
it('renders two datetime picker triggers', () => {
render(
<DateRangePicker
value={{ start: new Date(), end: new Date() }}
value={{ start: new Date('2026-03-19T10:00'), end: new Date('2026-03-19T11:00') }}
onChange={() => {}}
/>,
)
const inputs = container.querySelectorAll('input[type="datetime-local"]')
expect(inputs.length).toBe(2)
// DateTimePicker renders button triggers with formatted date text
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', () => {

View File

@@ -12,26 +12,217 @@
letter-spacing: 0.5px;
}
.input {
width: 100%;
padding: 6px 10px;
.trigger {
padding: 0 4px;
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-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
font-size: 13px;
text-align: center;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.input:focus {
.timeInput:focus {
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 {
opacity: 0.5;
cursor: pointer;
.timeSep {
font-size: 14px;
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 { forwardRef, type InputHTMLAttributes } from 'react'
interface DateTimePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {
interface DateTimePickerProps {
value?: Date
onChange?: (date: Date | null) => void
label?: string
placeholder?: string
className?: string
}
function toLocalDateTimeString(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
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 (
date.getFullYear() +
'-' +
pad(date.getMonth() + 1) +
'-' +
pad(date.getDate()) +
'T' +
pad(date.getHours()) +
':' +
pad(date.getMinutes())
<div className={`${styles.wrapper} ${className ?? ''}`}>
{label && <span className={styles.label}>{label}</span>}
<button
ref={triggerRef}
type="button"
className={styles.trigger}
onClick={() => setOpen(!open)}
>
{value ? formatDisplay(value) : (placeholder ?? '—')}
</button>
{open && createPortal(
<div
ref={panelRef}
className={styles.panel}
style={{ top: pos.top, left: pos.left }}
>
{/* Month navigation */}
<div className={styles.monthNav}>
<button type="button" className={styles.navBtn} onClick={prevMonth} aria-label="Previous month">&#9664;</button>
<span className={styles.monthLabel}>{monthLabel}</span>
<button type="button" className={styles.navBtn} onClick={nextMonth} aria-label="Next month">&#9654;</button>
</div>
{/* Calendar grid */}
<div className={styles.calendar}>
{DAYS.map((d) => (
<span key={d} className={styles.dayHeader}>{d}</span>
))}
{Array.from({ length: firstDay }, (_, i) => (
<span key={`pad-${i}`} className={styles.dayEmpty} />
))}
{Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const isToday = viewYear === today.getFullYear() && viewMonth === today.getMonth() && day === today.getDate()
const isSelected = selectedDate && viewYear === selectedDate.getFullYear() && viewMonth === selectedDate.getMonth() && day === selectedDate.getDate()
return (
<button
key={day}
type="button"
className={[styles.day, isToday ? styles.dayToday : '', isSelected ? styles.daySelected : ''].filter(Boolean).join(' ')}
onClick={() => handleDayClick(day)}
>
{day}
</button>
)
})}
</div>
{/* Time selector */}
<div className={styles.timeRow}>
<span className={styles.timeLabel}>Time</span>
<input
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'

View File

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

View File

@@ -1,3 +1,4 @@
import { forwardRef } from 'react'
import styles from './FilterPill.module.css'
interface FilterPillProps {
@@ -6,37 +7,48 @@ interface FilterPillProps {
active?: boolean
dot?: boolean
dotColor?: string
activeColor?: string
onClick?: () => void
className?: string
}
export function FilterPill({
label,
count,
active = false,
dot = false,
dotColor,
onClick,
className,
}: FilterPillProps) {
const classes = [
styles.pill,
active ? styles.active : '',
className ?? '',
].filter(Boolean).join(' ')
export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
({
label,
count,
active = false,
dot = false,
dotColor,
activeColor,
onClick,
className,
}, ref) => {
const classes = [
styles.pill,
active ? styles.active : '',
active && activeColor ? styles.activeColored : '',
className ?? '',
].filter(Boolean).join(' ')
return (
<button className={classes} onClick={onClick} type="button">
{dot && (
<span
className={styles.dot}
style={dotColor ? { background: dotColor } : undefined}
/>
)}
<span className={styles.label}>{label}</span>
{count !== undefined && (
<span className={styles.count}>{count}</span>
)}
</button>
)
}
const activeStyle = active && activeColor
? { borderColor: activeColor, backgroundColor: `color-mix(in srgb, ${activeColor} 12%, transparent)`, color: activeColor } as React.CSSProperties
: undefined
return (
<button ref={ref} className={classes} style={activeStyle} onClick={onClick} type="button" data-active={active || undefined}>
{dot && (
<span
className={`${styles.dot} ${!active ? styles.dotMuted : ''}`}
style={dotColor ? { background: active ? dotColor : undefined } : undefined}
/>
)}
<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 { Sparkline } from '../Sparkline/Sparkline'
import type { ReactNode } from 'react'
interface StatCardProps {
label: string
value: string | number
detail?: string
value: ReactNode
detail?: ReactNode
trend?: 'up' | 'down' | 'neutral'
trendValue?: string
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running'

View File

@@ -0,0 +1,7 @@
.statusText {}
.success { color: var(--success); }
.warning { color: var(--warning); }
.error { color: var(--error); }
.running { color: var(--running); }
.muted { color: var(--text-muted); }
.bold { font-weight: 600; }

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { StatusText } from './StatusText'
describe('StatusText', () => {
it('renders children text', () => {
render(<StatusText variant="success">Online</StatusText>)
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('renders as a span element', () => {
render(<StatusText variant="success">Status</StatusText>)
const el = screen.getByText('Status')
expect(el.tagName).toBe('SPAN')
})
it('applies variant class', () => {
render(<StatusText variant="error">Failed</StatusText>)
expect(screen.getByText('Failed')).toHaveClass('error')
})
it('applies bold class when bold=true', () => {
render(<StatusText variant="success" bold>OK</StatusText>)
expect(screen.getByText('OK')).toHaveClass('bold')
})
it('does not apply bold class by default', () => {
render(<StatusText variant="success">OK</StatusText>)
expect(screen.getByText('OK')).not.toHaveClass('bold')
})
it('accepts custom className', () => {
render(<StatusText variant="muted" className="custom">Text</StatusText>)
expect(screen.getByText('Text')).toHaveClass('custom')
})
it('renders all 5 variant classes correctly', () => {
const variants = ['success', 'warning', 'error', 'running', 'muted'] as const
for (const variant of variants) {
const { unmount } = render(
<StatusText variant={variant}>{variant}</StatusText>
)
expect(screen.getByText(variant)).toHaveClass(variant)
unmount()
}
})
})

View File

@@ -0,0 +1,20 @@
import styles from './StatusText.module.css'
import type { ReactNode } from 'react'
interface StatusTextProps {
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
bold?: boolean
children: ReactNode
className?: string
}
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
const classes = [
styles.statusText,
styles[variant],
bold ? styles.bold : '',
className ?? '',
].filter(Boolean).join(' ')
return <span className={classes}>{children}</span>
}

View File

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

View File

@@ -1,19 +1,21 @@
import { useState, useEffect } from 'react'
import styles from './TimeRangeDropdown.module.css'
import { Popover } from '../../composites/Popover/Popover'
import { FilterPill } from '../FilterPill/FilterPill'
import { computePresetRange, PRESET_SHORT_LABELS } from '../../utils/timePresets'
import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs'
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { computePresetRange } from '../../utils/timePresets'
import type { TimeRange } from '../../providers/GlobalFilterProvider'
const DROPDOWN_PRESETS = [
const PRESETS = [
{ value: 'last-1h', label: '1h' },
{ value: 'last-3h', label: '3h' },
{ value: 'last-6h', label: '6h' },
{ value: 'today', label: 'Today' },
{ value: 'shift', label: 'Shift' },
{ value: 'last-24h', label: '24h' },
{ value: 'last-7d', label: '7d' },
]
const CUSTOM_VALUE = '__custom__'
interface TimeRangeDropdownProps {
value: TimeRange
onChange: (range: TimeRange) => void
@@ -21,35 +23,78 @@ interface TimeRangeDropdownProps {
}
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
const activeLabel = value.preset ? (PRESET_SHORT_LABELS[value.preset] ?? value.preset) : 'Custom'
const [customFrom, setCustomFrom] = useState<Date>(value.start)
const [customTo, setCustomTo] = useState<Date>(value.end)
const [toIsSet, setToIsSet] = useState(false)
const isCustom = value.preset === null || value.preset === 'custom'
const activeValue = isCustom ? CUSTOM_VALUE : (value.preset ?? 'last-1h')
// Sync local state when value changes from presets
useEffect(() => {
setCustomFrom(value.start)
setCustomTo(value.end)
}, [value.start, value.end])
function handleTabChange(tabValue: string) {
if (tabValue === CUSTOM_VALUE) return
setToIsSet(false)
const range = computePresetRange(tabValue)
onChange({ ...range, preset: tabValue })
}
function handleFromChange(d: Date | null) {
if (!d) return
setCustomFrom(d)
// Only set preset to null; keep to-date as "now" if not explicitly set
if (toIsSet) {
onChange({ start: d, end: customTo, preset: null })
} else {
onChange({ start: d, end: new Date(), preset: null })
}
}
function handleToChange(d: Date | null) {
if (!d) return
setCustomTo(d)
setToIsSet(true)
onChange({ start: customFrom, end: d, preset: null })
}
// Show "now" when to-date is not explicitly set
const showNow = !isCustom || !toIsSet
const rangeContent = (
<div className={styles.rangeRow}>
<DateTimePicker
value={isCustom ? customFrom : value.start}
onChange={handleFromChange}
/>
<span className={styles.rangeSep}></span>
{showNow ? (
<DateTimePicker
value={undefined}
onChange={handleToChange}
placeholder="now"
/>
) : (
<DateTimePicker
value={customTo}
onChange={handleToChange}
/>
)}
</div>
)
return (
<Popover
className={className}
position="bottom"
align="start"
trigger={
<button className={styles.trigger} type="button" aria-label="Select time range">
<span className={styles.icon} aria-hidden="true">&#9201;</span>
<span className={styles.label}>{activeLabel}</span>
<span className={styles.caret} aria-hidden="true">&#9662;</span>
</button>
}
content={
<div className={styles.presetList}>
{DROPDOWN_PRESETS.map((preset) => (
<FilterPill
key={preset.value}
label={preset.label}
active={value.preset === preset.value}
onClick={() => {
const range = computePresetRange(preset.value)
onChange({ ...range, preset: preset.value })
}}
/>
))}
</div>
}
/>
<div 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 { Badge } from './Badge/Badge'
export { Button } from './Button/Button'
export { ButtonGroup } from './ButtonGroup/ButtonGroup'
export type { ButtonGroupItem } from './ButtonGroup/ButtonGroup'
export { Card } from './Card/Card'
export { Checkbox } from './Checkbox/Checkbox'
export { CodeBlock } from './CodeBlock/CodeBlock'
@@ -12,6 +14,8 @@ export { EmptyState } from './EmptyState/EmptyState'
export { FilterPill } from './FilterPill/FilterPill'
export { FormField } from './FormField/FormField'
export { InfoCallout } from './InfoCallout/InfoCallout'
export { InlineEdit } from './InlineEdit/InlineEdit'
export type { InlineEditProps } from './InlineEdit/InlineEdit'
export { Input } from './Input/Input'
export { KeyboardHint } from './KeyboardHint/KeyboardHint'
export { Label } from './Label/Label'
@@ -26,6 +30,7 @@ export { Sparkline } from './Sparkline/Sparkline'
export { Spinner } from './Spinner/Spinner'
export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot'
export { StatusText } from './StatusText/StatusText'
export { Tag } from './Tag/Tag'
export { Textarea } from './Textarea/Textarea'
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'

View File

@@ -16,20 +16,30 @@ interface GlobalFilterContextValue {
toggleStatus: (status: ExchangeStatus) => void
clearStatusFilters: () => void
isInTimeRange: (timestamp: Date) => boolean
autoRefresh: boolean
setAutoRefresh: (enabled: boolean) => void
}
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
const DEFAULT_PRESET = 'last-3h'
const DEFAULT_PRESET = 'last-1h'
function getDefaultTimeRange(): TimeRange {
const { start, end } = computePresetRange(DEFAULT_PRESET)
return { start, end, preset: DEFAULT_PRESET }
}
function getInitialAutoRefresh(): boolean {
try {
const stored = localStorage.getItem('cameleer:auto-refresh')
return stored === null ? true : stored === 'true'
} catch { return true }
}
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
const setTimeRange = useCallback((range: TimeRange) => {
setTimeRangeState(range)
@@ -51,6 +61,11 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
setStatusFilters(new Set())
}, [])
const setAutoRefresh = useCallback((enabled: boolean) => {
setAutoRefreshState(enabled)
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
}, [])
const isInTimeRange = useCallback(
(timestamp: Date) => {
if (timeRange.preset) {
@@ -65,7 +80,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
return (
<GlobalFilterContext.Provider
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
>
{children}
</GlobalFilterContext.Provider>

View File

@@ -10,8 +10,8 @@
--sidebar-bg: #2C2520;
--sidebar-hover: #3A322C;
--sidebar-active: #4A3F38;
--sidebar-text: #BFB5A8;
--sidebar-muted: #7A6F63;
--sidebar-text: #D8D0C6;
--sidebar-muted: #9C9184;
/* Text */
--text-primary: #1A1612;
@@ -58,6 +58,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);
/* Accent: purple (for choice/router elements) */
--purple: #7C3AED;
--purple-bg: #F3EEFA;
/* Chart palette */
--chart-1: #C6820E;
--chart-2: #3D7C47;
@@ -80,7 +84,7 @@
--sidebar-bg: #141210;
--sidebar-hover: #1E1B17;
--sidebar-active: #28241E;
--sidebar-text: #A89E92;
--sidebar-text: #CCC4B8;
--sidebar-muted: #6A6058;
--text-primary: #E8E0D6;
@@ -109,6 +113,9 @@
--running-bg: #1A2628;
--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-md: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);

View File

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

View File

@@ -12,7 +12,6 @@ export const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 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' },
@@ -23,7 +22,6 @@ export const PRESET_SHORT_LABELS: Record<string, string> = {
'last-3h': '3h',
'last-6h': '6h',
'today': 'Today',
'shift': 'Shift',
'last-24h': '24h',
'last-7d': '7d',
'custom': 'Custom',
@@ -45,10 +43,6 @@ export function computePresetRange(preset: string): DateRange {
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':

View File

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

View File

@@ -20,6 +20,7 @@ export interface Exchange {
errorMessage?: string
errorClass?: string
processors: ProcessorData[]
correlationGroup?: string
}
export const exchanges: Exchange[] = [
@@ -34,6 +35,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:12:04'),
correlationId: 'cmr-f4a1c82b-9d3e',
agent: 'prod-1',
correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-7b2d9f14-c5a8',
agent: 'prod-2',
correlationGroup: 'payment-flow-001',
processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-3c8e1a7f-d2b6',
agent: 'prod-1',
correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-a9f3b2c1-e4d7',
agent: 'prod-3',
correlationGroup: 'shipment-flow-001',
processors: [
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 6, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-9a4f2b71-e8c3',
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).',
errorClass: 'org.apache.camel.CamelExecutionException',
processors: [
@@ -145,6 +151,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:00:15'),
correlationId: 'cmr-2e5f8d9a-b4c1',
agent: 'prod-3',
correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-d1a3e7f4-c2b8',
agent: 'prod-1',
correlationGroup: 'payment-flow-001',
processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-f3c7a1b9-d5e2',
agent: 'prod-1',
correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-a2d8f5c3-b9e1',
agent: 'prod-2',
correlationGroup: 'payment-flow-001',
processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-7e9a2c5f-d1b4',
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)',
errorClass: 'org.apache.camel.component.http.HttpOperationFailedException',
processors: [
@@ -273,6 +284,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:22:44'),
correlationId: 'cmr-b5c8d2a7-f4e3',
agent: 'prod-3',
correlationGroup: 'shipment-flow-001',
processors: [
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ 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'),
correlationId: 'cmr-d9e3f7b1-a6c5',
agent: 'prod-4',
correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 4 },

View File

@@ -20,7 +20,7 @@ export interface MetricSeries {
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(
baseValue: number,
variance: number,
@@ -44,12 +44,12 @@ function generateTimeSeries(
// KPI stat cards data
export const kpiMetrics: KpiMetric[] = [
{
label: 'Exchanges (shift)',
label: 'Exchanges',
value: '3,241',
trend: 'up',
trendValue: '+12%',
trendSentiment: 'good',
detail: '97.1% success since 06:00',
detail: '97.1% success rate',
accent: 'amber',
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],
},
{
label: 'Errors (shift)',
label: 'Errors',
value: 38,
trend: 'up',
trendValue: '+5',
trendSentiment: 'bad',
detail: '23 overnight · 15 since 06:00',
detail: '38 errors in selected period',
accent: 'error',
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 {
routeId: string
routeName: string
appId: string
exchangeCount: number
successRate: number
avgDurationMs: number
@@ -159,6 +160,7 @@ export const routeMetrics: RouteMetricRow[] = [
{
routeId: 'order-intake',
routeName: 'order-intake',
appId: 'order-service',
exchangeCount: 892,
successRate: 99.2,
avgDurationMs: 88,
@@ -169,6 +171,7 @@ export const routeMetrics: RouteMetricRow[] = [
{
routeId: 'order-enrichment',
routeName: 'order-enrichment',
appId: 'order-service',
exchangeCount: 541,
successRate: 97.6,
avgDurationMs: 156,
@@ -179,6 +182,7 @@ export const routeMetrics: RouteMetricRow[] = [
{
routeId: 'payment-process',
routeName: 'payment-process',
appId: 'payment-svc',
exchangeCount: 414,
successRate: 96.1,
avgDurationMs: 234,
@@ -186,9 +190,21 @@ export const routeMetrics: RouteMetricRow[] = [
errorCount: 16,
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',
routeName: 'shipment-dispatch',
appId: 'shipment-tracker',
exchangeCount: 387,
successRate: 98.4,
avgDurationMs: 118,
@@ -196,4 +212,26 @@ export const routeMetrics: RouteMetricRow[] = [
errorCount: 6,
sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118],
},
{
routeId: 'shipment-track',
routeName: 'shipment-track',
appId: 'shipment-tracker',
exchangeCount: 923,
successRate: 99.5,
avgDurationMs: 94,
p99DurationMs: 167,
errorCount: 5,
sparkline: [88, 91, 93, 95, 92, 94, 96, 93, 91, 95, 94, 92, 93, 94],
},
{
routeId: 'notification-dispatch',
routeName: 'notification-dispatch',
appId: 'notification-hub',
exchangeCount: 471,
successRate: 98.9,
avgDurationMs: 62,
p99DurationMs: 124,
errorCount: 5,
sparkline: [58, 60, 63, 61, 64, 62, 60, 63, 65, 62, 61, 63, 62, 62],
},
]

View File

@@ -2,7 +2,7 @@ import type { SearchResult } from '../design-system/composites/CommandPalette/ty
import { exchanges, type Exchange } from './exchanges'
import { routes } from './routes'
import { agents } from './agents'
import { SIDEBAR_APPS, type SidebarApp } from './sidebar'
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
@@ -72,14 +72,16 @@ export function buildSearchData(
})
}
const routeToApp = buildRouteToAppMap(apps)
for (const route of rts) {
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: `/routes/${route.id}`,
path: appIdForRoute ? `/apps/${appIdForRoute}/${route.id}` : `/apps/${route.id}`,
})
}

View File

@@ -20,6 +20,17 @@ export interface SidebarApp {
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[] = [
{
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 { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
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 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 (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[{ label: 'Admin' }]}
breadcrumb={[
{ label: 'Admin', href: '/admin' },
{ label: title },
]}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<EmptyState
title="Admin Panel"
description="Admin panel coming soon."
<Tabs
tabs={ADMIN_TABS}
active={location.pathname}
onChange={(path) => navigate(path)}
/>
<div className={styles.adminContent}>
{children}
</div>
</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,297 @@
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 { SplitPane } from '../../../design-system/composites/SplitPane/SplitPane'
import { EntityList } from '../../../design-system/composites/EntityList/EntityList'
import { useToast } from '../../../design-system/composites/Toast/Toast'
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, 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 (
<>
<SplitPane
list={
<>
{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>
)}
<EntityList
items={filtered}
renderItem={(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 (
<>
<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>
</>
)
}}
getItemId={(group) => group.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search groups..."
onSearch={setSearch}
addLabel="+ Add group"
onAdd={() => setCreating(true)}
emptyMessage="No groups match your search"
/>
</>
}
detail={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>
</>
) : null}
emptyMessage="Select a group to view details"
/>
<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,212 @@
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 { SplitPane } from '../../../design-system/composites/SplitPane/SplitPane'
import { EntityList } from '../../../design-system/composites/EntityList/EntityList'
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 (
<>
<SplitPane
list={
<>
{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>
)}
<EntityList
items={filtered}
renderItem={(role) => (
<>
<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>
</>
)}
getItemId={(role) => role.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search roles..."
onSearch={setSearch}
addLabel="+ Add role"
onAdd={() => setCreating(true)}
emptyMessage="No roles match your search"
/>
</>
}
detail={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>
)}
</>
) : null}
emptyMessage="Select a role to view details"
/>
<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,151 @@
.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;
}
.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;
}
.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>
)
}

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