Compare commits

...

13 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Applied to TopBar status pills and EventFeed severity pills.

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:23:13 +01:00
54 changed files with 4406 additions and 1434 deletions

View File

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

View File

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

111
README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,10 @@ export type { MultiSelectOption } from './MultiSelect/MultiSelect'
export { Popover } from './Popover/Popover' export { Popover } from './Popover/Popover'
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline' export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline' export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
export { RouteFlow } from './RouteFlow/RouteFlow'
export type { RouteNode } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar' export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { Tabs } from './Tabs/Tabs' export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast' export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView' export { TreeView } from './TreeView/TreeView'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ export { Avatar } from './Avatar/Avatar'
export { Badge } from './Badge/Badge' export { Badge } from './Badge/Badge'
export { Button } from './Button/Button' export { Button } from './Button/Button'
export { ButtonGroup } from './ButtonGroup/ButtonGroup' export { ButtonGroup } from './ButtonGroup/ButtonGroup'
export type { ButtonGroupItem } from './ButtonGroup/ButtonGroup'
export { Card } from './Card/Card' export { Card } from './Card/Card'
export { Checkbox } from './Checkbox/Checkbox' export { Checkbox } from './Checkbox/Checkbox'
export { CodeBlock } from './CodeBlock/CodeBlock' export { CodeBlock } from './CodeBlock/CodeBlock'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ import {
MultiSelect, MultiSelect,
Popover, Popover,
ProcessorTimeline, ProcessorTimeline,
RouteFlow,
SegmentedTabs,
ShortcutsBar, ShortcutsBar,
Tabs, Tabs,
ToastProvider, ToastProvider,
@@ -227,6 +229,7 @@ export function CompositesSection() {
{ label: 'Agents', value: 'agents', count: 6 }, { label: 'Agents', value: 'agents', count: 6 },
] ]
const [activeTab, setActiveTab] = useState('overview') const [activeTab, setActiveTab] = useState('overview')
const [segTab, setSegTab] = useState('account')
// 21. TreeView // 21. TreeView
const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1') const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1')
@@ -605,6 +608,28 @@ export function CompositesSection() {
</div> </div>
</DemoCard> </DemoCard>
{/* 17b. RouteFlow */}
<DemoCard
id="routeflow"
title="RouteFlow"
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
>
<div style={{ width: '100%', maxWidth: 360 }}>
<RouteFlow
nodes={[
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
{ name: 'sql:INSERT INTO orders', type: 'to', durationMs: 24, status: 'ok' },
{ name: 'header.priority == HIGH', type: 'choice', durationMs: 1, status: 'ok' },
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow', isBottleneck: true },
{ name: 'ResponseMapper', type: 'process', durationMs: 3, status: 'ok' },
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
]}
/>
</div>
</DemoCard>
{/* 18. ShortcutsBar */} {/* 18. ShortcutsBar */}
<DemoCard <DemoCard
id="shortcutsbar" id="shortcutsbar"
@@ -636,6 +661,28 @@ export function CompositesSection() {
</div> </div>
</DemoCard> </DemoCard>
{/* 19b. SegmentedTabs */}
<DemoCard
id="segmented-tabs"
title="SegmentedTabs"
description="Pill-style segmented tab bar with elevated active state. Same API as Tabs."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<SegmentedTabs
tabs={[
{ label: 'Account', value: 'account' },
{ label: 'Password', value: 'password' },
{ label: 'Notifications', value: 'notifications', count: 3 },
]}
active={segTab}
onChange={setSegTab}
/>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Active tab: <strong>{segTab}</strong>
</div>
</div>
</DemoCard>
{/* 20. Toast */} {/* 20. Toast */}
<DemoCard <DemoCard
id="toast" id="toast"

View File

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

View File

@@ -5,6 +5,7 @@ import {
Avatar, Avatar,
Badge, Badge,
Button, Button,
ButtonGroup,
Card, Card,
Checkbox, Checkbox,
CodeBlock, CodeBlock,
@@ -72,6 +73,9 @@ export function PrimitivesSection() {
// Alert state // Alert state
const [alertDismissed, setAlertDismissed] = useState(false) const [alertDismissed, setAlertDismissed] = useState(false)
// ButtonGroup state
const [bgSelection, setBgSelection] = useState<Set<string>>(new Set(['warn']))
// Checkbox state // Checkbox state
const [checked1, setChecked1] = useState(false) const [checked1, setChecked1] = useState(false)
const [checked2, setChecked2] = useState(true) const [checked2, setChecked2] = useState(true)
@@ -178,6 +182,24 @@ export function PrimitivesSection() {
</div> </div>
</DemoCard> </DemoCard>
{/* 4b. ButtonGroup */}
<DemoCard
id="buttongroup"
title="ButtonGroup"
description="Multi-select toggle group with optional colored dot indicators. Used for status filters."
>
<ButtonGroup
items={[
{ value: 'ok', label: 'OK', color: 'var(--success)' },
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
{ value: 'error', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]}
value={bgSelection}
onChange={setBgSelection}
/>
</DemoCard>
{/* 5. Card */} {/* 5. Card */}
<DemoCard <DemoCard
id="card" id="card"

View File

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

View File

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

View File

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

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

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