Compare commits
13 Commits
f075968e66
...
v0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b959edd6c7 | ||
|
|
ff9f1aa519 | ||
|
|
91788737b0 | ||
|
|
5bd965e59a | ||
|
|
e21d920fe3 | ||
|
|
a92ada8117 | ||
|
|
932dc9dcbd | ||
|
|
9c9063dc1b | ||
|
|
4f3e9c0f35 | ||
|
|
daf53ad499 | ||
|
|
8418b89a77 | ||
|
|
fdf45d0d94 | ||
|
|
d9483ec4d1 |
@@ -23,17 +23,21 @@ jobs:
|
||||
run: npm run build:lib
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
TAG="latest"
|
||||
else
|
||||
;;
|
||||
*)
|
||||
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
|
||||
DATE=$(date +%Y%m%d)
|
||||
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
|
||||
TAG="dev"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
|
||||
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
|
||||
npm publish --tag "$TAG"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- Page-level attention banner → **Alert**
|
||||
- Temporary non-blocking feedback → **Toast** (via `useToast`)
|
||||
- Destructive action confirmation → **AlertDialog**
|
||||
- Destructive action needing typed confirmation → **ConfirmDialog**
|
||||
- Generic dialog with custom content → **Modal**
|
||||
|
||||
### "I need a form input"
|
||||
@@ -19,6 +20,8 @@
|
||||
- Yes/no with label → **Checkbox**
|
||||
- One of N options (≤5) → **RadioGroup** + **RadioItem**
|
||||
- One of N options (>5) → **Select**
|
||||
- Select multiple from a list → **MultiSelect**
|
||||
- Edit text inline without a form → **InlineEdit**
|
||||
- Date/time → **DateTimePicker**
|
||||
- Date range → **DateRangePicker**
|
||||
- Wrap any input with label/error/hint → **FormField**
|
||||
@@ -52,12 +55,14 @@
|
||||
- Categorical comparison → **BarChart**
|
||||
- Inline trend → **Sparkline**
|
||||
- Event log → **EventFeed**
|
||||
- Processing pipeline → **ProcessorTimeline**
|
||||
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||
- Processing pipeline (flow diagram) → **RouteFlow**
|
||||
|
||||
### "I need to organize content"
|
||||
- Collapsible sections (standalone) → **Collapsible**
|
||||
- Multiple collapsible sections (one/many open) → **Accordion**
|
||||
- Tabbed content → **Tabs**
|
||||
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||
- Side panel inspector → **DetailPanel**
|
||||
- Section with title + action → **SectionHeader**
|
||||
- Empty content placeholder → **EmptyState**
|
||||
@@ -73,12 +78,15 @@
|
||||
- Single user avatar → **Avatar**
|
||||
- Stacked user avatars → **AvatarGroup**
|
||||
|
||||
### "I need to group buttons"
|
||||
- Connected button strip (toggle group, segmented control) → **ButtonGroup** (horizontal or vertical)
|
||||
### "I need to group buttons or toggle selections"
|
||||
- Multi-select toggle group with colored indicators → **ButtonGroup** (e.g., status filters)
|
||||
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||
|
||||
### "I need filtering"
|
||||
- Filter pill/chip → **FilterPill**
|
||||
- Multi-select status/category filter → **ButtonGroup** (toggle items on/off)
|
||||
- Filter pill/chip → **FilterPill** (individual toggleable pills)
|
||||
- Full filter bar with search → **FilterBar**
|
||||
- Select multiple from a list → **MultiSelect**
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
@@ -109,9 +117,11 @@ Below: charts (AreaChart, LineChart, BarChart)
|
||||
|
||||
### Detail/inspector pattern
|
||||
```
|
||||
DetailPanel (right slide) with Tabs for sections
|
||||
Each tab: Cards with data, CodeBlock for payloads,
|
||||
ProcessorTimeline for exchange flow
|
||||
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
|
||||
Tabbed: use tabs prop for multiple panels
|
||||
Scrollable: use children for stacked sections (overview, errors, route flow, timeline)
|
||||
Each section: Cards with data, CodeBlock for payloads,
|
||||
ProcessorTimeline or RouteFlow for exchange flow
|
||||
```
|
||||
|
||||
### Feedback flow
|
||||
@@ -151,16 +161,17 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
||||
| BarChart | composite | Categorical data comparison, optional stacking |
|
||||
| Breadcrumb | composite | Navigation path showing current location |
|
||||
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
||||
| 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 |
|
||||
| Checkbox | primitive | Boolean input with label |
|
||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||
| Collapsible | primitive | Single expand/collapse section |
|
||||
| CommandPalette | composite | Full-screen search and command interface |
|
||||
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||
| DateRangePicker | primitive | Date range selection with presets |
|
||||
| DateTimePicker | primitive | Single date/time input |
|
||||
| DetailPanel | composite | Slide-in side panel with tabs |
|
||||
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
||||
| Dropdown | composite | Action menu triggered by any element |
|
||||
| EmptyState | primitive | Placeholder for empty content areas |
|
||||
| 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 |
|
||||
| FormField | primitive | Wrapper adding label, hint, error to any input |
|
||||
| InfoCallout | primitive | Inline contextual note with variant colors |
|
||||
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
||||
| Input | primitive | Single-line text input with optional icon |
|
||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
||||
| Label | primitive | Form label with optional required asterisk |
|
||||
| LineChart | composite | Time series line visualization |
|
||||
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||
| Modal | composite | Generic dialog overlay with backdrop |
|
||||
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
|
||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||
| Pagination | primitive | Page navigation controls |
|
||||
| Popover | composite | Click-triggered floating panel with arrow |
|
||||
| ProcessorTimeline | composite | Pipeline exchange visualization |
|
||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||
| SectionHeader | primitive | Section title with optional action button |
|
||||
| SegmentedTabs | composite | Pill-style segmented tab bar with sliding animated indicator. Same API as Tabs but with elevated active state. Props: tabs, active, onChange, trailing, trailingValue, className |
|
||||
| Select | primitive | Dropdown select input |
|
||||
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
||||
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
||||
@@ -203,8 +218,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
||||
| Sidebar | Hierarchical navigation with Applications/Agents trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
||||
| TopBar | Header bar with breadcrumb, environment, user info |
|
||||
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
||||
|
||||
## Import Paths
|
||||
|
||||
|
||||
111
README.md
Normal file
111
README.md
Normal 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
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Dashboard } from './pages/Dashboard/Dashboard'
|
||||
import { Metrics } from './pages/Metrics/Metrics'
|
||||
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
||||
import { Routes as RoutesPage } from './pages/Routes/Routes'
|
||||
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
|
||||
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
|
||||
import { AgentInstance } from './pages/AgentInstance/AgentInstance'
|
||||
import { Inventory } from './pages/Inventory/Inventory'
|
||||
import { AuditLog } from './pages/Admin/AuditLog/AuditLog'
|
||||
import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig'
|
||||
@@ -19,32 +19,31 @@ import { buildSearchData } from './mocks/searchData'
|
||||
import { exchanges } from './mocks/exchanges'
|
||||
import { routes } from './mocks/routes'
|
||||
import { agents } from './mocks/agents'
|
||||
import { SIDEBAR_APPS } from './mocks/sidebar'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
|
||||
|
||||
const routeToApp = buildRouteToAppMap()
|
||||
|
||||
/** Compute which sidebar path to reveal for a given search result */
|
||||
function computeSidebarRevealPath(result: SearchResult): string | undefined {
|
||||
if (!result.path) return undefined
|
||||
|
||||
if (result.category === 'application') {
|
||||
// /apps/:id — already a sidebar node path
|
||||
return result.path
|
||||
}
|
||||
|
||||
if (result.category === 'route') {
|
||||
// /routes/:id — already a sidebar node path
|
||||
return result.path
|
||||
}
|
||||
|
||||
if (result.category === 'agent') {
|
||||
// /agents/:appId/:agentId — already a sidebar node path
|
||||
return result.path
|
||||
}
|
||||
|
||||
if (result.category === 'exchange') {
|
||||
// /exchanges/:id — no sidebar entry; resolve to the parent route
|
||||
const exchange = exchanges.find((e) => e.id === result.id)
|
||||
if (exchange) {
|
||||
return `/routes/${exchange.route}`
|
||||
const appId = routeToApp.get(exchange.route)
|
||||
if (appId) return `/apps/${appId}/${exchange.route}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +81,12 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="/apps" element={<Dashboard />} />
|
||||
<Route path="/apps/:id" element={<Dashboard />} />
|
||||
<Route path="/metrics" element={<Metrics />} />
|
||||
<Route path="/routes/:id" element={<RouteDetail />} />
|
||||
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/routes/:appId" element={<RoutesPage />} />
|
||||
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
|
||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
|
||||
<Route path="/agents/*" element={<AgentHealth />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||||
<Route path="/admin/audit" element={<AuditLog />} />
|
||||
|
||||
@@ -11,15 +11,16 @@ interface DetailPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
tabs: Tab[]
|
||||
tabs?: Tab[]
|
||||
children?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DetailPanel({ open, onClose, title, tabs, actions, className }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(tabs[0]?.value ?? '')
|
||||
export function DetailPanel({ open, onClose, title, tabs, children, actions, className }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(tabs?.[0]?.value ?? '')
|
||||
|
||||
const activeContent = tabs.find((t) => t.value === activeTab)?.content
|
||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -38,7 +39,7 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tabs.length > 0 && (
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
@@ -54,7 +55,7 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
|
||||
)}
|
||||
|
||||
<div className={styles.body}>
|
||||
{activeContent}
|
||||
{children ?? activeContent}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import styles from './EventFeed.module.css'
|
||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
|
||||
export interface FeedEvent {
|
||||
id: string
|
||||
@@ -53,6 +54,13 @@ const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
||||
running: '\u2699', // ⚙
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
||||
error: 'var(--error)',
|
||||
warning: 'var(--warning)',
|
||||
success: 'var(--success)',
|
||||
running: 'var(--running)',
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<SeverityFilter, string> = {
|
||||
error: 'Error',
|
||||
warning: 'Warning',
|
||||
@@ -133,18 +141,15 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.filters}>
|
||||
{allSeverities.map((sev) => {
|
||||
const count = events.filter((e) => e.severity === sev).length
|
||||
return (
|
||||
<FilterPill
|
||||
key={sev}
|
||||
label={SEVERITY_LABELS[sev]}
|
||||
count={count}
|
||||
active={activeFilters.has(sev)}
|
||||
onClick={() => toggleFilter(sev)}
|
||||
<ButtonGroup
|
||||
items={allSeverities.map((sev): ButtonGroupItem => ({
|
||||
value: sev,
|
||||
label: SEVERITY_LABELS[sev],
|
||||
color: SEVERITY_COLORS[sev],
|
||||
}))}
|
||||
value={activeFilters as Set<string>}
|
||||
onChange={(next) => setActiveFilters(next as Set<SeverityFilter>)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{activeFilters.size > 0 && (
|
||||
<button
|
||||
className={styles.clearBtn}
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
.item:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: #E8DFD4;
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
@@ -69,5 +69,5 @@
|
||||
|
||||
.item.active .count {
|
||||
background: rgba(198, 130, 14, 0.2);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
@@ -69,15 +69,15 @@
|
||||
}
|
||||
|
||||
.ok {
|
||||
background: rgba(61, 124, 71, 0.5);
|
||||
background: color-mix(in srgb, var(--success) 50%, transparent);
|
||||
}
|
||||
|
||||
.slow {
|
||||
background: rgba(194, 117, 22, 0.5);
|
||||
background: color-mix(in srgb, var(--warning) 50%, transparent);
|
||||
}
|
||||
|
||||
.fail {
|
||||
background: rgba(192, 57, 43, 0.5);
|
||||
background: color-mix(in srgb, var(--error) 50%, transparent);
|
||||
}
|
||||
|
||||
.dur {
|
||||
@@ -89,6 +89,13 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.selectedRow {
|
||||
background: var(--amber-bg);
|
||||
border-left: 3px solid var(--amber);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 0 2px 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -11,7 +11,8 @@ export interface ProcessorStep {
|
||||
interface ProcessorTimelineProps {
|
||||
processors: ProcessorStep[]
|
||||
totalMs: number
|
||||
onProcessorClick?: (processor: ProcessorStep) => void
|
||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||
selectedIndex?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ export function ProcessorTimeline({
|
||||
processors,
|
||||
totalMs,
|
||||
onProcessorClick,
|
||||
selectedIndex,
|
||||
className,
|
||||
}: ProcessorTimelineProps) {
|
||||
const safeTotal = totalMs || 1
|
||||
@@ -49,17 +51,19 @@ export function ProcessorTimeline({
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const isSelected = selectedIndex === i
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.row} ${onProcessorClick ? styles.clickable : ''}`}
|
||||
onClick={() => onProcessorClick?.(proc)}
|
||||
className={`${styles.row} ${onProcessorClick ? styles.clickable : ''} ${isSelected ? styles.selectedRow : ''}`}
|
||||
onClick={() => onProcessorClick?.(proc, i)}
|
||||
role={onProcessorClick ? 'button' : undefined}
|
||||
tabIndex={onProcessorClick ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (onProcessorClick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
onProcessorClick(proc)
|
||||
onProcessorClick(proc, i)
|
||||
}
|
||||
}}
|
||||
aria-label={`${proc.name}: ${formatDuration(proc.durationMs)} (${proc.status})`}
|
||||
|
||||
204
src/design-system/composites/RouteFlow/RouteFlow.module.css
Normal file
204
src/design-system/composites/RouteFlow/RouteFlow.module.css
Normal 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;
|
||||
}
|
||||
133
src/design-system/composites/RouteFlow/RouteFlow.tsx
Normal file
133
src/design-system/composites/RouteFlow/RouteFlow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
101
src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx
Normal file
101
src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,10 @@ export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||
export { Popover } from './Popover/Popover'
|
||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||
export { Tabs } from './Tabs/Tabs'
|
||||
export { ToastProvider, useToast } from './Toast/Toast'
|
||||
export { TreeView } from './TreeView/TreeView'
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
.logoImg {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
filter: brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
|
||||
.item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
}
|
||||
|
||||
.item.active .navIcon {
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.routeArrow {
|
||||
@@ -197,8 +197,9 @@
|
||||
/* ── SidebarTree styles ──────────────────────────────────────────────────── */
|
||||
|
||||
.treeSection {
|
||||
padding: 0 6px;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 6px 6px;
|
||||
margin-bottom: 2px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.treeSectionLabel {
|
||||
@@ -214,9 +215,9 @@
|
||||
.treeSectionToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 12px 4px;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.treeSectionChevronBtn {
|
||||
@@ -248,11 +249,11 @@
|
||||
}
|
||||
|
||||
.treeSectionLabel:hover {
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.treeSectionLabelActive {
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.tree {
|
||||
@@ -289,13 +290,13 @@
|
||||
|
||||
.treeRowActive {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.treeRowActive .treeBadge {
|
||||
background: rgba(198, 130, 14, 0.2);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* Chevron */
|
||||
@@ -379,7 +380,7 @@
|
||||
}
|
||||
|
||||
.treeStar:hover {
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ── Starred section ─────────────────────────────────────────────────────── */
|
||||
@@ -499,7 +500,7 @@
|
||||
|
||||
.bottomItemActive {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Agents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Metrics nav link', () => {
|
||||
it('renders Routes nav link', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Metrics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Routes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders bottom links', () => {
|
||||
@@ -87,9 +87,9 @@ describe('Sidebar', () => {
|
||||
|
||||
it('renders app names in the Applications tree', () => {
|
||||
renderSidebar()
|
||||
// order-service appears in both Applications and Agents trees
|
||||
// order-service appears in Applications, Routes, and Agents trees
|
||||
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('payment-svc')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders exchange count badges', () => {
|
||||
@@ -130,8 +130,8 @@ describe('Sidebar', () => {
|
||||
const searchInput = screen.getByPlaceholderText('Filter...')
|
||||
await user.type(searchInput, 'payment')
|
||||
|
||||
// payment-svc should still be visible
|
||||
expect(screen.getByText('payment-svc')).toBeInTheDocument()
|
||||
// payment-svc should still be visible (may appear in multiple trees)
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('expands tree to show children when chevron is clicked', async () => {
|
||||
|
||||
@@ -57,7 +57,30 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
label: route.name,
|
||||
icon: <span className={styles.routeArrow}>▸</span>,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${route.id}`,
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.routes.length > 0)
|
||||
.map((app) => ({
|
||||
id: `routes:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: `${app.routes.length} routes`,
|
||||
path: `/routes/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `routes:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `routestat:${app.id}:${route.id}`,
|
||||
starKey: `routes:${app.id}:${route.id}`,
|
||||
label: route.name,
|
||||
icon: <span className={styles.routeArrow}>▸</span>,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
@@ -95,7 +118,7 @@ interface StarredItem {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
path: string
|
||||
type: 'application' | 'route' | 'agent'
|
||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
||||
parentApp?: string
|
||||
}
|
||||
|
||||
@@ -118,24 +141,57 @@ function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): Starr
|
||||
items.push({
|
||||
starKey: key,
|
||||
label: route.name,
|
||||
path: `/routes/${route.id}`,
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
type: 'route',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
const agentsAppKey = `agents:${app.id}`
|
||||
if (starredIds.has(agentsAppKey)) {
|
||||
items.push({
|
||||
starKey: agentsAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/agents/${app.id}`,
|
||||
type: 'agent',
|
||||
})
|
||||
}
|
||||
for (const agent of app.agents) {
|
||||
const key = `${app.id}:${agent.id}`
|
||||
if (starredIds.has(key)) {
|
||||
items.push({
|
||||
starKey: key,
|
||||
label: agent.name,
|
||||
path: `/agents/${agent.id}`,
|
||||
path: `/agents/${app.id}/${agent.id}`,
|
||||
type: 'agent',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Routes tree starred items
|
||||
const routesAppKey = `routes:${app.id}`
|
||||
if (starredIds.has(routesAppKey)) {
|
||||
items.push({
|
||||
starKey: routesAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/routes/${app.id}`,
|
||||
type: 'routestat',
|
||||
})
|
||||
}
|
||||
for (const route of app.routes) {
|
||||
const routeKey = `routes:${app.id}:${route.id}`
|
||||
if (starredIds.has(routeKey)) {
|
||||
items.push({
|
||||
starKey: routeKey,
|
||||
label: route.name,
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
type: 'routestat',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
@@ -196,6 +252,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
|
||||
|
||||
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setAppsCollapsed((prev) => {
|
||||
@@ -212,6 +269,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setRoutesCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||
@@ -219,6 +284,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
// Build tree data
|
||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
||||
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
|
||||
|
||||
// Sidebar reveal from Cmd-K navigation (passed via location state)
|
||||
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||
@@ -254,6 +320,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
const starredApps = starredItems.filter((i) => i.type === 'application')
|
||||
const starredRoutes = starredItems.filter((i) => i.type === 'route')
|
||||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||||
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
|
||||
const hasStarred = starredItems.length > 0
|
||||
|
||||
// For exchange detail pages, use the reveal path for sidebar selection so
|
||||
@@ -374,23 +441,38 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flat nav links */}
|
||||
<div className={styles.items}>
|
||||
<div
|
||||
className={[
|
||||
styles.item,
|
||||
location.pathname === '/metrics' ? styles.active : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => navigate('/metrics')}
|
||||
{/* Routes tree (collapsible, label navigates to /routes) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setRoutesCollapsed((v) => !v)}
|
||||
aria-expanded={!routesCollapsed}
|
||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||
>
|
||||
{routesCollapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => navigate('/routes')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
||||
>
|
||||
<span className={styles.navIcon}>▤</span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>Metrics</div>
|
||||
</div>
|
||||
Routes
|
||||
</span>
|
||||
</div>
|
||||
{!routesCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={routeNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:routes"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No results message */}
|
||||
@@ -429,6 +511,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredRouteStats.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRouteStats}
|
||||
onNavigate={navigate}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -81,6 +81,27 @@
|
||||
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 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
@@ -100,6 +121,7 @@
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.userName {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import styles from './TopBar.module.css'
|
||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||
import { Avatar } from '../../primitives/Avatar/Avatar'
|
||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
|
||||
import { useGlobalFilters, type ExchangeStatus } from '../../providers/GlobalFilterProvider'
|
||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
||||
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||
import { useTheme } from '../../providers/ThemeProvider'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
@@ -15,24 +18,27 @@ interface TopBarProps {
|
||||
breadcrumb: BreadcrumbItem[]
|
||||
environment?: string
|
||||
user?: { name: string }
|
||||
onLogout?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const STATUS_PILLS: { status: ExchangeStatus; label: string }[] = [
|
||||
{ status: 'completed', label: 'OK' },
|
||||
{ status: 'warning', label: 'Warn' },
|
||||
{ status: 'failed', label: 'Error' },
|
||||
{ status: 'running', label: 'Running' },
|
||||
const STATUS_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'completed', label: 'OK', color: 'var(--success)' },
|
||||
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
|
||||
{ value: 'failed', label: 'Error', color: 'var(--error)' },
|
||||
{ value: 'running', label: 'Running', color: 'var(--running)' },
|
||||
]
|
||||
|
||||
export function TopBar({
|
||||
breadcrumb,
|
||||
environment,
|
||||
user,
|
||||
onLogout,
|
||||
className,
|
||||
}: TopBarProps) {
|
||||
const globalFilters = useGlobalFilters()
|
||||
const commandPalette = useCommandPalette()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||
@@ -56,17 +62,21 @@ export function TopBar({
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
</button>
|
||||
|
||||
{/* Status pills */}
|
||||
<div className={styles.filters}>
|
||||
{STATUS_PILLS.map(({ status, label }) => (
|
||||
<FilterPill
|
||||
key={status}
|
||||
label={label}
|
||||
active={globalFilters.statusFilters.has(status)}
|
||||
onClick={() => globalFilters.toggleStatus(status)}
|
||||
{/* Status filter group */}
|
||||
<ButtonGroup
|
||||
items={STATUS_ITEMS}
|
||||
value={globalFilters.statusFilters}
|
||||
onChange={(selected) => {
|
||||
// Sync with global filter by toggling the diff
|
||||
const current = globalFilters.statusFilters
|
||||
for (const v of selected) {
|
||||
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
||||
}
|
||||
for (const v of current) {
|
||||
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time range pills */}
|
||||
<TimeRangeDropdown
|
||||
@@ -74,16 +84,32 @@ export function TopBar({
|
||||
onChange={globalFilters.setTimeRange}
|
||||
/>
|
||||
|
||||
{/* Right: env badge, user */}
|
||||
{/* Right: theme toggle, env badge, user */}
|
||||
<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 && (
|
||||
<span className={styles.env}>{environment}</span>
|
||||
)}
|
||||
{user && (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<div className={styles.user}>
|
||||
<span className={styles.userName}>{user.name}</span>
|
||||
<Avatar name={user.name} size="md" />
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,60 +1,59 @@
|
||||
.group {
|
||||
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);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* Vertical */
|
||||
.vertical {
|
||||
flex-direction: column;
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vertical > :global(*) {
|
||||
border-radius: 0;
|
||||
margin-top: -1px;
|
||||
position: relative;
|
||||
.btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.vertical > :global(*:first-child) {
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
margin-top: 0;
|
||||
.btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vertical > :global(*:last-child) {
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
}
|
||||
|
||||
.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) {
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--amber);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Active state — default (no color override) */
|
||||
.active {
|
||||
background: var(--amber-bg);
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Dot indicator */
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotMuted {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,60 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import styles from './ButtonGroup.module.css'
|
||||
|
||||
export interface ButtonGroupItem {
|
||||
value: string
|
||||
label: ReactNode
|
||||
/** Optional color for dot indicator and active tint */
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface ButtonGroupProps {
|
||||
children: ReactNode
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
items: ButtonGroupItem[]
|
||||
/** Currently selected values (multi-select) */
|
||||
value: Set<string>
|
||||
onChange: (value: Set<string>) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ButtonGroup({
|
||||
children,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
}: ButtonGroupProps) {
|
||||
export function ButtonGroup({ items, value, onChange, className }: ButtonGroupProps) {
|
||||
function handleClick(itemValue: string) {
|
||||
const next = new Set(value)
|
||||
if (next.has(itemValue)) {
|
||||
next.delete(itemValue)
|
||||
} else {
|
||||
next.add(itemValue)
|
||||
}
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.group} ${styles[orientation]} ${className ?? ''}`}
|
||||
role="group"
|
||||
<div className={`${styles.group} ${className ?? ''}`} role="group">
|
||||
{items.map((item) => {
|
||||
const active = value.has(item.value)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`${styles.btn} ${active ? styles.active : ''}`}
|
||||
style={active && item.color ? {
|
||||
borderColor: item.color,
|
||||
color: item.color,
|
||||
background: `color-mix(in srgb, ${item.color} 10%, transparent)`,
|
||||
} : undefined}
|
||||
onClick={() => handleClick(item.value)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{children}
|
||||
{item.color && (
|
||||
<span
|
||||
className={`${styles.dot} ${active ? '' : styles.dotMuted}`}
|
||||
style={{ background: item.color }}
|
||||
/>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import userEvent from '@testing-library/user-event'
|
||||
import { DateRangePicker } from './DateRangePicker'
|
||||
|
||||
describe('DateRangePicker', () => {
|
||||
it('renders two datetime inputs', () => {
|
||||
const { container } = render(
|
||||
it('renders two datetime picker triggers', () => {
|
||||
render(
|
||||
<DateRangePicker
|
||||
value={{ start: new Date(), end: new Date() }}
|
||||
value={{ start: new Date('2026-03-19T10:00'), end: new Date('2026-03-19T11:00') }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
)
|
||||
const inputs = container.querySelectorAll('input[type="datetime-local"]')
|
||||
expect(inputs.length).toBe(2)
|
||||
// DateTimePicker renders button triggers with formatted date text
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// At least 2 buttons are the from/to date picker triggers (plus preset pills)
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('renders preset buttons', () => {
|
||||
|
||||
@@ -12,26 +12,217 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
.trigger {
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
outline: 1px solid var(--amber);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 600;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 12px;
|
||||
width: 260px;
|
||||
animation: panelIn 0.12s ease-out;
|
||||
}
|
||||
|
||||
@keyframes panelIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Month navigation */
|
||||
.monthNav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.monthLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.navBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Calendar grid */
|
||||
.calendar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dayHeader {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.dayEmpty {
|
||||
/* placeholder for offset days */
|
||||
}
|
||||
|
||||
.day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.dayToday {
|
||||
font-weight: 700;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.daySelected {
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.daySelected:hover {
|
||||
background: var(--amber-hover);
|
||||
}
|
||||
|
||||
/* Time selector */
|
||||
.timeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.timeLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.timeInput {
|
||||
width: 32px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
.timeInput:focus {
|
||||
border-color: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||
box-shadow: 0 0 0 2px var(--amber-bg);
|
||||
}
|
||||
|
||||
.input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
.timeSep {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.todayBtn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.todayBtn:hover {
|
||||
background: var(--amber-bg);
|
||||
}
|
||||
|
||||
.doneBtn {
|
||||
padding: 4px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.doneBtn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.doneBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,51 +1,204 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './DateTimePicker.module.css'
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||
|
||||
interface DateTimePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {
|
||||
interface DateTimePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | null) => void
|
||||
label?: string
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function toLocalDateTimeString(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
date.getFullYear() +
|
||||
'-' +
|
||||
pad(date.getMonth() + 1) +
|
||||
'-' +
|
||||
pad(date.getDate()) +
|
||||
'T' +
|
||||
pad(date.getHours()) +
|
||||
':' +
|
||||
pad(date.getMinutes())
|
||||
)
|
||||
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
export const DateTimePicker = forwardRef<HTMLInputElement, DateTimePickerProps>(
|
||||
({ value, onChange, label, className, ...rest }, ref) => {
|
||||
const inputValue = value ? toLocalDateTimeString(value) : ''
|
||||
function getFirstDayOfWeek(year: number, month: number): number {
|
||||
const day = new Date(year, month, 1).getDay()
|
||||
return day === 0 ? 6 : day - 1 // Monday = 0
|
||||
}
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (!onChange) return
|
||||
const v = e.target.value
|
||||
onChange(v ? new Date(v) : null)
|
||||
function formatDisplay(d: Date | undefined): string {
|
||||
if (!d) return '—'
|
||||
const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
return `${date}\u2009${time}`
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function DateTimePicker({ value, onChange, label, placeholder, className }: DateTimePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth())
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(value ?? null)
|
||||
const [hour, setHour] = useState(value ? pad(value.getHours()) : pad(new Date().getHours()))
|
||||
const [minute, setMinute] = useState(value ? pad(value.getMinutes()) : pad(new Date().getMinutes()))
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
// Sync when value changes externally
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelectedDate(value)
|
||||
setHour(pad(value.getHours()))
|
||||
setMinute(pad(value.getMinutes()))
|
||||
setViewYear(value.getFullYear())
|
||||
setViewMonth(value.getMonth())
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
setPos({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const id = requestAnimationFrame(reposition)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}
|
||||
}, [open, reposition])
|
||||
|
||||
// Close on Escape only — panel closes via Apply/Now buttons
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [open])
|
||||
|
||||
function handleDone() {
|
||||
if (selectedDate) {
|
||||
const d = new Date(selectedDate)
|
||||
d.setHours(parseInt(hour, 10) || 0, parseInt(minute, 10) || 0, 0, 0)
|
||||
onChange?.(d)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleDayClick(day: number) {
|
||||
const d = new Date(viewYear, viewMonth, day)
|
||||
setSelectedDate(d)
|
||||
}
|
||||
|
||||
function handleNow() {
|
||||
const now = new Date()
|
||||
onChange?.(now)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1) }
|
||||
else setViewMonth((m) => m - 1)
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1) }
|
||||
else setViewMonth((m) => m + 1)
|
||||
}
|
||||
|
||||
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
|
||||
const firstDay = getFirstDayOfWeek(viewYear, viewMonth)
|
||||
const today = new Date()
|
||||
|
||||
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
|
||||
return (
|
||||
<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">◀</button>
|
||||
<span className={styles.monthLabel}>{monthLabel}</span>
|
||||
<button type="button" className={styles.navBtn} onClick={nextMonth} aria-label="Next month">▶</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
|
||||
ref={ref}
|
||||
type="datetime-local"
|
||||
className={styles.input}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
type="text"
|
||||
className={styles.timeInput}
|
||||
value={hour}
|
||||
onChange={(e) => setHour(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||
maxLength={2}
|
||||
aria-label="Hour"
|
||||
/>
|
||||
<span className={styles.timeSep}>:</span>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.timeInput}
|
||||
value={minute}
|
||||
onChange={(e) => setMinute(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||
maxLength={2}
|
||||
aria-label="Minute"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.todayBtn} onClick={handleNow}>Now</button>
|
||||
<button type="button" className={styles.doneBtn} onClick={handleDone} disabled={!selectedDate}>Apply</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
DateTimePicker.displayName = 'DateTimePicker'
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotMuted {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.activeColored {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface FilterPillProps {
|
||||
active?: boolean
|
||||
dot?: boolean
|
||||
dotColor?: string
|
||||
activeColor?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
@@ -18,21 +19,27 @@ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
|
||||
active = false,
|
||||
dot = false,
|
||||
dotColor,
|
||||
activeColor,
|
||||
onClick,
|
||||
className,
|
||||
}, ref) => {
|
||||
const classes = [
|
||||
styles.pill,
|
||||
active ? styles.active : '',
|
||||
active && activeColor ? styles.activeColored : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
const activeStyle = active && activeColor
|
||||
? { borderColor: activeColor, backgroundColor: `color-mix(in srgb, ${activeColor} 12%, transparent)`, color: activeColor } as React.CSSProperties
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<button ref={ref} className={classes} onClick={onClick} type="button" data-active={active || undefined}>
|
||||
<button ref={ref} className={classes} style={activeStyle} onClick={onClick} type="button" data-active={active || undefined}>
|
||||
{dot && (
|
||||
<span
|
||||
className={styles.dot}
|
||||
style={dotColor ? { background: dotColor } : undefined}
|
||||
className={`${styles.dot} ${!active ? styles.dotMuted : ''}`}
|
||||
style={dotColor ? { background: active ? dotColor : undefined } : undefined}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.label}>{label}</span>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import styles from './StatCard.module.css'
|
||||
import { Sparkline } from '../Sparkline/Sparkline'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
detail?: string
|
||||
value: ReactNode
|
||||
detail?: ReactNode
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
trendValue?: string
|
||||
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running'
|
||||
|
||||
@@ -1,78 +1,11 @@
|
||||
/* ── Integrated readout cell ──────────────────────────────
|
||||
First child of the ButtonGroup — styled as a recessed
|
||||
instrument-panel display, not a clickable control. */
|
||||
|
||||
.readout {
|
||||
.rangeRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
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;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .readout {
|
||||
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);
|
||||
.rangeSep {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.applyBtn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.applyBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import styles from './TimeRangeDropdown.module.css'
|
||||
import { FilterPill } from '../FilterPill/FilterPill'
|
||||
import { ButtonGroup } from '../ButtonGroup/ButtonGroup'
|
||||
import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs'
|
||||
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
||||
import { computePresetRange } from '../../utils/timePresets'
|
||||
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 = [
|
||||
{ value: 'last-1h', label: '1h' },
|
||||
{ value: 'last-3h', label: '3h' },
|
||||
@@ -45,6 +14,8 @@ const PRESETS = [
|
||||
{ value: 'last-7d', label: '7d' },
|
||||
]
|
||||
|
||||
const CUSTOM_VALUE = '__custom__'
|
||||
|
||||
interface TimeRangeDropdownProps {
|
||||
value: TimeRange
|
||||
onChange: (range: TimeRange) => void
|
||||
@@ -52,112 +23,78 @@ interface TimeRangeDropdownProps {
|
||||
}
|
||||
|
||||
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customFrom, setCustomFrom] = useState<Date | null>(value.start)
|
||||
const [customTo, setCustomTo] = useState<Date | null>(value.end)
|
||||
const customRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 })
|
||||
const [customFrom, setCustomFrom] = useState<Date>(value.start)
|
||||
const [customTo, setCustomTo] = useState<Date>(value.end)
|
||||
const [toIsSet, setToIsSet] = useState(false)
|
||||
|
||||
const isCustom = value.preset === null || value.preset === 'custom'
|
||||
const activeValue = isCustom ? CUSTOM_VALUE : (value.preset ?? 'last-1h')
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
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,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Sync local state when value changes from presets
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const id = requestAnimationFrame(reposition)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}
|
||||
}, [open, reposition])
|
||||
setCustomFrom(value.start)
|
||||
setCustomTo(value.end)
|
||||
}, [value.start, value.end])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (
|
||||
customRef.current?.contains(e.target as Node) ||
|
||||
panelRef.current?.contains(e.target as Node)
|
||||
) return
|
||||
setOpen(false)
|
||||
function handleTabChange(tabValue: string) {
|
||||
if (tabValue === CUSTOM_VALUE) return
|
||||
setToIsSet(false)
|
||||
const range = computePresetRange(tabValue)
|
||||
onChange({ ...range, preset: tabValue })
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<ButtonGroup className={className}>
|
||||
{PRESETS.map((preset) => (
|
||||
<FilterPill
|
||||
key={preset.value}
|
||||
label={preset.label}
|
||||
active={value.preset === preset.value}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
const range = computePresetRange(preset.value)
|
||||
onChange({ ...range, preset: preset.value })
|
||||
}}
|
||||
<div className={className}>
|
||||
<SegmentedTabs
|
||||
tabs={PRESETS}
|
||||
active={activeValue}
|
||||
onChange={handleTabChange}
|
||||
trailing={rangeContent}
|
||||
trailingValue={CUSTOM_VALUE}
|
||||
/>
|
||||
))}
|
||||
<FilterPill
|
||||
ref={customRef}
|
||||
label="Custom"
|
||||
active={isCustom}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
/>
|
||||
<span className={styles.readout} aria-label="Active time range">
|
||||
{rangeLabel}
|
||||
</span>
|
||||
</ButtonGroup>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={styles.panel}
|
||||
style={{ top: panelPos.top, left: panelPos.left }}
|
||||
role="dialog"
|
||||
>
|
||||
<DateTimePicker
|
||||
label="From"
|
||||
value={customFrom ?? undefined}
|
||||
onChange={(d) => setCustomFrom(d)}
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="To"
|
||||
value={customTo ?? undefined}
|
||||
onChange={(d) => setCustomTo(d)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.applyBtn}
|
||||
disabled={!customFrom || !customTo}
|
||||
onClick={() => {
|
||||
if (customFrom && customTo) {
|
||||
onChange({ start: customFrom, end: customTo, preset: null })
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export { Avatar } from './Avatar/Avatar'
|
||||
export { Badge } from './Badge/Badge'
|
||||
export { Button } from './Button/Button'
|
||||
export { ButtonGroup } from './ButtonGroup/ButtonGroup'
|
||||
export type { ButtonGroupItem } from './ButtonGroup/ButtonGroup'
|
||||
export { Card } from './Card/Card'
|
||||
export { Checkbox } from './Checkbox/Checkbox'
|
||||
export { CodeBlock } from './CodeBlock/CodeBlock'
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
--sidebar-bg: #2C2520;
|
||||
--sidebar-hover: #3A322C;
|
||||
--sidebar-active: #4A3F38;
|
||||
--sidebar-text: #BFB5A8;
|
||||
--sidebar-muted: #7A6F63;
|
||||
--sidebar-text: #D8D0C6;
|
||||
--sidebar-muted: #9C9184;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #1A1612;
|
||||
@@ -58,6 +58,10 @@
|
||||
--shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10);
|
||||
--shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04);
|
||||
|
||||
/* Accent: purple (for choice/router elements) */
|
||||
--purple: #7C3AED;
|
||||
--purple-bg: #F3EEFA;
|
||||
|
||||
/* Chart palette */
|
||||
--chart-1: #C6820E;
|
||||
--chart-2: #3D7C47;
|
||||
@@ -80,7 +84,7 @@
|
||||
--sidebar-bg: #141210;
|
||||
--sidebar-hover: #1E1B17;
|
||||
--sidebar-active: #28241E;
|
||||
--sidebar-text: #A89E92;
|
||||
--sidebar-text: #CCC4B8;
|
||||
--sidebar-muted: #6A6058;
|
||||
|
||||
--text-primary: #E8E0D6;
|
||||
@@ -109,6 +113,9 @@
|
||||
--running-bg: #1A2628;
|
||||
--running-border: #243A3E;
|
||||
|
||||
--purple: #A78BFA;
|
||||
--purple-bg: rgba(124, 58, 237, 0.15);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
|
||||
@@ -12,7 +12,6 @@ export const DEFAULT_PRESETS: Preset[] = [
|
||||
{ label: 'Last 1h', value: 'last-1h' },
|
||||
{ label: 'Last 6h', value: 'last-6h' },
|
||||
{ label: 'Today', value: 'today' },
|
||||
{ label: 'This shift', value: 'shift' },
|
||||
{ label: 'Last 24h', value: 'last-24h' },
|
||||
{ label: 'Last 7d', value: 'last-7d' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
@@ -23,7 +22,6 @@ export const PRESET_SHORT_LABELS: Record<string, string> = {
|
||||
'last-3h': '3h',
|
||||
'last-6h': '6h',
|
||||
'today': 'Today',
|
||||
'shift': 'Shift',
|
||||
'last-24h': '24h',
|
||||
'last-7d': '7d',
|
||||
'custom': 'Custom',
|
||||
@@ -45,10 +43,6 @@ export function computePresetRange(preset: string): DateRange {
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return { start, end }
|
||||
}
|
||||
case 'shift': {
|
||||
// "This shift" = last 8 hours
|
||||
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
|
||||
}
|
||||
case 'last-24h':
|
||||
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
|
||||
case 'last-7d':
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Exchange {
|
||||
errorMessage?: string
|
||||
errorClass?: string
|
||||
processors: ProcessorData[]
|
||||
correlationGroup?: string
|
||||
}
|
||||
|
||||
export const exchanges: Exchange[] = [
|
||||
@@ -34,6 +35,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:12:04'),
|
||||
correlationId: 'cmr-f4a1c82b-9d3e',
|
||||
agent: 'prod-1',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
|
||||
@@ -53,6 +55,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:11:22'),
|
||||
correlationId: 'cmr-7b2d9f14-c5a8',
|
||||
agent: 'prod-2',
|
||||
correlationGroup: 'payment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 5 },
|
||||
@@ -72,6 +75,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:13:44'),
|
||||
correlationId: 'cmr-3c8e1a7f-d2b6',
|
||||
agent: 'prod-1',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||
{ name: 'enrich(inventory-api)', type: 'enrich', durationMs: 29990, status: 'slow', startMs: 5 },
|
||||
@@ -88,6 +92,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:09:47'),
|
||||
correlationId: 'cmr-a9f3b2c1-e4d7',
|
||||
agent: 'prod-3',
|
||||
correlationGroup: 'shipment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 6, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 6 },
|
||||
@@ -106,6 +111,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:06:11'),
|
||||
correlationId: 'cmr-9a4f2b71-e8c3',
|
||||
agent: 'prod-2',
|
||||
correlationGroup: 'payment-flow-002',
|
||||
errorMessage: 'org.apache.camel.CamelExecutionException: Payment gateway timeout after 5000ms — POST https://pay.provider.com/v2/charge returned HTTP 504. Retry exhausted (3/3).',
|
||||
errorClass: 'org.apache.camel.CamelExecutionException',
|
||||
processors: [
|
||||
@@ -145,6 +151,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T09:00:15'),
|
||||
correlationId: 'cmr-2e5f8d9a-b4c1',
|
||||
agent: 'prod-3',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 5, status: 'ok', startMs: 3 },
|
||||
@@ -164,6 +171,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:58:33'),
|
||||
correlationId: 'cmr-d1a3e7f4-c2b8',
|
||||
agent: 'prod-1',
|
||||
correlationGroup: 'payment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||
{ name: 'validate(payment-schema)', type: 'process', durationMs: 14, status: 'ok', startMs: 4 },
|
||||
@@ -199,6 +207,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:50:41'),
|
||||
correlationId: 'cmr-f3c7a1b9-d5e2',
|
||||
agent: 'prod-1',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 3 },
|
||||
@@ -218,6 +227,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:46:19'),
|
||||
correlationId: 'cmr-a2d8f5c3-b9e1',
|
||||
agent: 'prod-2',
|
||||
correlationGroup: 'payment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||
{ name: 'validate(payment-schema)', type: 'process', durationMs: 12, status: 'ok', startMs: 5 },
|
||||
@@ -254,6 +264,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:31:05'),
|
||||
correlationId: 'cmr-7e9a2c5f-d1b4',
|
||||
agent: 'prod-2',
|
||||
correlationGroup: 'payment-flow-002',
|
||||
errorMessage: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP operation failed invoking https://pay.provider.com/v2/charge with statusCode: 422 — Unprocessable Entity: card declined (insufficient funds)',
|
||||
errorClass: 'org.apache.camel.component.http.HttpOperationFailedException',
|
||||
processors: [
|
||||
@@ -273,6 +284,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:22:44'),
|
||||
correlationId: 'cmr-b5c8d2a7-f4e3',
|
||||
agent: 'prod-3',
|
||||
correlationGroup: 'shipment-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 5 },
|
||||
@@ -291,6 +303,7 @@ export const exchanges: Exchange[] = [
|
||||
timestamp: new Date('2026-03-18T08:15:19'),
|
||||
correlationId: 'cmr-d9e3f7b1-a6c5',
|
||||
agent: 'prod-4',
|
||||
correlationGroup: 'order-flow-001',
|
||||
processors: [
|
||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 4 },
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface MetricSeries {
|
||||
data: TimeSeriesPoint[]
|
||||
}
|
||||
|
||||
// Generate a realistic time series for the past shift (06:00 - now ~09:15)
|
||||
// Generate a realistic time series for the past hours (06:00 - now ~09:15)
|
||||
function generateTimeSeries(
|
||||
baseValue: number,
|
||||
variance: number,
|
||||
@@ -44,12 +44,12 @@ function generateTimeSeries(
|
||||
// KPI stat cards data
|
||||
export const kpiMetrics: KpiMetric[] = [
|
||||
{
|
||||
label: 'Exchanges (shift)',
|
||||
label: 'Exchanges',
|
||||
value: '3,241',
|
||||
trend: 'up',
|
||||
trendValue: '+12%',
|
||||
trendSentiment: 'good',
|
||||
detail: '97.1% success since 06:00',
|
||||
detail: '97.1% success rate',
|
||||
accent: 'amber',
|
||||
sparkline: [28, 32, 29, 35, 38, 41, 37, 44, 42, 47, 45, 51, 48, 52],
|
||||
},
|
||||
@@ -64,12 +64,12 @@ export const kpiMetrics: KpiMetric[] = [
|
||||
sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1],
|
||||
},
|
||||
{
|
||||
label: 'Errors (shift)',
|
||||
label: 'Errors',
|
||||
value: 38,
|
||||
trend: 'up',
|
||||
trendValue: '+5',
|
||||
trendSentiment: 'bad',
|
||||
detail: '23 overnight · 15 since 06:00',
|
||||
detail: '38 errors in selected period',
|
||||
accent: 'error',
|
||||
sparkline: [1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6, 8],
|
||||
},
|
||||
@@ -147,6 +147,7 @@ export const errorCountSeries: MetricSeries[] = [
|
||||
export interface RouteMetricRow {
|
||||
routeId: string
|
||||
routeName: string
|
||||
appId: string
|
||||
exchangeCount: number
|
||||
successRate: number
|
||||
avgDurationMs: number
|
||||
@@ -159,6 +160,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
{
|
||||
routeId: 'order-intake',
|
||||
routeName: 'order-intake',
|
||||
appId: 'order-service',
|
||||
exchangeCount: 892,
|
||||
successRate: 99.2,
|
||||
avgDurationMs: 88,
|
||||
@@ -169,6 +171,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
{
|
||||
routeId: 'order-enrichment',
|
||||
routeName: 'order-enrichment',
|
||||
appId: 'order-service',
|
||||
exchangeCount: 541,
|
||||
successRate: 97.6,
|
||||
avgDurationMs: 156,
|
||||
@@ -179,6 +182,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
{
|
||||
routeId: 'payment-process',
|
||||
routeName: 'payment-process',
|
||||
appId: 'payment-svc',
|
||||
exchangeCount: 414,
|
||||
successRate: 96.1,
|
||||
avgDurationMs: 234,
|
||||
@@ -186,9 +190,21 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
errorCount: 16,
|
||||
sparkline: [210, 225, 232, 218, 241, 235, 228, 242, 238, 231, 244, 237, 233, 234],
|
||||
},
|
||||
{
|
||||
routeId: 'payment-validate',
|
||||
routeName: 'payment-validate',
|
||||
appId: 'payment-svc',
|
||||
exchangeCount: 498,
|
||||
successRate: 99.8,
|
||||
avgDurationMs: 142,
|
||||
p99DurationMs: 198,
|
||||
errorCount: 1,
|
||||
sparkline: [138, 141, 140, 143, 145, 142, 144, 141, 139, 143, 142, 140, 141, 142],
|
||||
},
|
||||
{
|
||||
routeId: 'shipment-dispatch',
|
||||
routeName: 'shipment-dispatch',
|
||||
appId: 'shipment-tracker',
|
||||
exchangeCount: 387,
|
||||
successRate: 98.4,
|
||||
avgDurationMs: 118,
|
||||
@@ -196,4 +212,26 @@ export const routeMetrics: RouteMetricRow[] = [
|
||||
errorCount: 6,
|
||||
sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118],
|
||||
},
|
||||
{
|
||||
routeId: 'shipment-track',
|
||||
routeName: 'shipment-track',
|
||||
appId: 'shipment-tracker',
|
||||
exchangeCount: 923,
|
||||
successRate: 99.5,
|
||||
avgDurationMs: 94,
|
||||
p99DurationMs: 167,
|
||||
errorCount: 5,
|
||||
sparkline: [88, 91, 93, 95, 92, 94, 96, 93, 91, 95, 94, 92, 93, 94],
|
||||
},
|
||||
{
|
||||
routeId: 'notification-dispatch',
|
||||
routeName: 'notification-dispatch',
|
||||
appId: 'notification-hub',
|
||||
exchangeCount: 471,
|
||||
successRate: 98.9,
|
||||
avgDurationMs: 62,
|
||||
p99DurationMs: 124,
|
||||
errorCount: 5,
|
||||
sparkline: [58, 60, 63, 61, 64, 62, 60, 63, 65, 62, 61, 63, 62, 62],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SearchResult } from '../design-system/composites/CommandPalette/ty
|
||||
import { exchanges, type Exchange } from './exchanges'
|
||||
import { routes } from './routes'
|
||||
import { agents } from './agents'
|
||||
import { SIDEBAR_APPS, type SidebarApp } from './sidebar'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||
@@ -72,14 +72,16 @@ export function buildSearchData(
|
||||
})
|
||||
}
|
||||
|
||||
const routeToApp = buildRouteToAppMap(apps)
|
||||
for (const route of rts) {
|
||||
const appIdForRoute = routeToApp.get(route.id)
|
||||
results.push({
|
||||
id: route.id,
|
||||
category: 'route',
|
||||
title: route.name,
|
||||
badges: [{ label: route.group }],
|
||||
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
|
||||
path: `/routes/${route.id}`,
|
||||
path: appIdForRoute ? `/apps/${appIdForRoute}/${route.id}` : `/apps/${route.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,17 @@ export interface SidebarApp {
|
||||
agents: SidebarAgent[]
|
||||
}
|
||||
|
||||
/** Build a routeId → appId lookup from the sidebar tree */
|
||||
export function buildRouteToAppMap(apps: SidebarApp[] = SIDEBAR_APPS): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
for (const app of apps) {
|
||||
for (const route of app.routes) {
|
||||
map.set(route.id, app.id)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const SIDEBAR_APPS: SidebarApp[] = [
|
||||
{
|
||||
id: 'order-service',
|
||||
|
||||
@@ -10,11 +10,27 @@
|
||||
/* Stat strip */
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
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 */
|
||||
.scopeTrail {
|
||||
display: flex;
|
||||
@@ -178,11 +194,6 @@
|
||||
box-shadow: inset 3px 0 0 var(--amber);
|
||||
}
|
||||
|
||||
/* Chart expansion row */
|
||||
.chartRow td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Instance fields */
|
||||
.instanceName {
|
||||
font-weight: 600;
|
||||
@@ -211,17 +222,35 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Instance expanded charts */
|
||||
.instanceCharts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
/* Detail panel content */
|
||||
.detailContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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);
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detailProgress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.chartPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import styles from './AgentHealth.module.css'
|
||||
|
||||
// Layout
|
||||
@@ -11,12 +11,14 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
|
||||
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
||||
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
||||
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
||||
|
||||
// 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'
|
||||
|
||||
// Global filters
|
||||
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||
@@ -31,13 +33,11 @@ import { agentEvents } from '../../mocks/agentEvents'
|
||||
type Scope =
|
||||
| { level: 'all' }
|
||||
| { level: 'app'; appId: string }
|
||||
| { level: 'instance'; appId: string; instanceId: string }
|
||||
|
||||
function useScope(): Scope {
|
||||
const { '*': rest } = useParams()
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -106,11 +106,8 @@ function buildBreadcrumb(scope: Scope) {
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
{ label: 'Agents', href: '/agents' },
|
||||
]
|
||||
if (scope.level === 'app' || scope.level === 'instance') {
|
||||
crumbs.push({ label: scope.appId, href: `/agents/${scope.appId}` })
|
||||
}
|
||||
if (scope.level === 'instance') {
|
||||
crumbs.push({ label: scope.instanceId })
|
||||
if (scope.level === 'app') {
|
||||
crumbs.push({ label: scope.appId })
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
@@ -119,14 +116,14 @@ function buildBreadcrumb(scope: Scope) {
|
||||
|
||||
export function AgentHealth() {
|
||||
const scope = useScope()
|
||||
const navigate = useNavigate()
|
||||
const { isInTimeRange } = useGlobalFilters()
|
||||
const [selectedInstance, setSelectedInstance] = useState<AgentHealthData | null>(null)
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
|
||||
// Filter agents by scope
|
||||
const filteredAgents = useMemo(() => {
|
||||
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 && a.id === scope.instanceId)
|
||||
return agents.filter((a) => a.appId === scope.appId)
|
||||
}, [scope])
|
||||
|
||||
const groups = useMemo(() => groupByApp(filteredAgents), [filteredAgents])
|
||||
@@ -138,18 +135,132 @@ export function AgentHealth() {
|
||||
const deadCount = filteredAgents.filter((a) => a.status === 'dead').length
|
||||
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 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
|
||||
const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp))
|
||||
|
||||
// Single instance for expanded charts
|
||||
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
||||
const trendData = singleInstance ? buildTrendData(singleInstance) : null
|
||||
// Build trend data for selected instance
|
||||
const trendData = selectedInstance ? buildTrendData(selectedInstance) : 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'
|
||||
|
||||
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
|
||||
breadcrumb={buildBreadcrumb(scope)}
|
||||
environment="PRODUCTION"
|
||||
@@ -159,36 +270,61 @@ export function AgentHealth() {
|
||||
<div className={styles.content}>
|
||||
{/* Stat strip */}
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Total Instances" value={String(totalInstances)} />
|
||||
<StatCard label="Live" value={String(liveCount)} accent="success" />
|
||||
<StatCard label="Stale" value={String(staleCount)} accent={staleCount > 0 ? 'warning' : undefined} />
|
||||
<StatCard label="Dead" value={String(deadCount)} accent={deadCount > 0 ? 'error' : undefined} />
|
||||
<StatCard label="Total TPS" value={`${totalTps.toFixed(1)}/s`} />
|
||||
<StatCard label="Active Routes" value={String(totalActiveRoutes)} />
|
||||
<StatCard
|
||||
label="Total Agents"
|
||||
value={String(totalInstances)}
|
||||
accent={deadCount > 0 ? 'warning' : 'amber'}
|
||||
detail={
|
||||
<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>
|
||||
|
||||
{/* Scope breadcrumb trail */}
|
||||
{scope.level !== 'all' && (
|
||||
{/* Scope trail + badges */}
|
||||
<div className={styles.scopeTrail}>
|
||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||
{scope.level === 'instance' && (
|
||||
{scope.level !== 'all' && (
|
||||
<>
|
||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<Link to={`/agents/${scope.appId}`} className={styles.scopeLink}>{scope.appId}</Link>
|
||||
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.scopeSep}>▸</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
|
||||
label={`${liveCount}/${totalInstances} live`}
|
||||
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
|
||||
@@ -240,14 +376,13 @@ export function AgentHealth() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.instances.map((inst) => (
|
||||
<>
|
||||
<tr
|
||||
key={inst.id}
|
||||
className={[
|
||||
styles.instanceRow,
|
||||
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '',
|
||||
selectedInstance?.id === inst.id && panelOpen ? styles.instanceRowActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => navigate(`/agents/${inst.appId}/${inst.id}`)}
|
||||
onClick={() => handleInstanceClick(inst)}
|
||||
>
|
||||
<td className={styles.tdStatus}>
|
||||
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
||||
@@ -283,35 +418,6 @@ export function AgentHealth() {
|
||||
</MonoText>
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
|
||||
229
src/pages/AgentInstance/AgentInstance.module.css
Normal file
229
src/pages/AgentInstance/AgentInstance.module.css
Normal 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);
|
||||
}
|
||||
326
src/pages/AgentInstance/AgentInstance.tsx
Normal file
326
src/pages/AgentInstance/AgentInstance.tsx
Normal 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}>▸</span>
|
||||
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
||||
<span className={styles.scopeSep}>▸</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>
|
||||
)
|
||||
}
|
||||
@@ -69,14 +69,9 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.routeGroup {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Customer text */
|
||||
.customerText {
|
||||
/* Application column */
|
||||
.appName {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -146,12 +141,46 @@
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Detail panel: overview tab */
|
||||
.overviewTab {
|
||||
padding: 16px;
|
||||
/* Detail panel sections */
|
||||
.panelSection {
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overviewRow {
|
||||
@@ -166,17 +195,17 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
width: 100px;
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Error block */
|
||||
.errorBlock {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.errorClass {
|
||||
@@ -184,7 +213,7 @@
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--error);
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
@@ -192,40 +221,45 @@
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import styles from './Dashboard.module.css'
|
||||
|
||||
// Layout
|
||||
@@ -13,6 +13,8 @@ import type { Column } from '../../design-system/composites/DataTable/types'
|
||||
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
||||
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
|
||||
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
|
||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
||||
@@ -26,7 +28,10 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
||||
// Mock data
|
||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -36,7 +41,13 @@ function formatDuration(ms: number): 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' {
|
||||
@@ -57,8 +68,8 @@ function statusLabel(status: Exchange['status']): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Table columns ────────────────────────────────────────────────────────────
|
||||
const COLUMNS: Column<Exchange>[] = [
|
||||
// ─── Table columns (base, without navigate action) ──────────────────────────
|
||||
const BASE_COLUMNS: Column<Exchange>[] = [
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
@@ -75,25 +86,23 @@ const COLUMNS: Column<Exchange>[] = [
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div className={styles.routeName}>{row.route}</div>
|
||||
<div className={styles.routeGroup}>{row.routeGroup}</div>
|
||||
</div>
|
||||
<span className={styles.routeName}>{row.route}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'orderId',
|
||||
header: 'Order ID',
|
||||
key: 'routeGroup',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.orderId}</MonoText>
|
||||
<span className={styles.appName}>{ROUTE_TO_APP.get(row.route) ?? row.routeGroup}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
header: 'Customer',
|
||||
key: 'id',
|
||||
header: 'Exchange ID',
|
||||
sortable: true,
|
||||
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 ──────────────────────────────────────────────────────
|
||||
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 [panelOpen, setPanelOpen] = useState(false)
|
||||
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}`)
|
||||
}}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
),
|
||||
}
|
||||
const [statusCol, ...rest] = BASE_COLUMNS
|
||||
return [statusCol, inspectCol, ...rest]
|
||||
}, [navigate])
|
||||
|
||||
const { isInTimeRange, statusFilters } = useGlobalFilters()
|
||||
|
||||
// Build set of route IDs belonging to the selected app (if any)
|
||||
@@ -158,11 +191,12 @@ export function Dashboard() {
|
||||
return new Set(app.routes.map((r) => r.id))
|
||||
}, [appId])
|
||||
|
||||
// Scope all data to the selected app
|
||||
// Scope all data to the selected app (and optionally route)
|
||||
const scopedExchanges = useMemo(() => {
|
||||
if (routeId) return exchanges.filter((e) => e.route === routeId)
|
||||
if (!appRouteIds) return exchanges
|
||||
return exchanges.filter((e) => appRouteIds.has(e.route))
|
||||
}, [appRouteIds])
|
||||
}, [appRouteIds, routeId])
|
||||
|
||||
// Filter exchanges (scoped + global filters)
|
||||
const filteredExchanges = useMemo(() => {
|
||||
@@ -191,98 +225,33 @@ export function Dashboard() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Build detail panel tabs for selected exchange
|
||||
const detailTabs = selectedExchange
|
||||
? [
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
content: (
|
||||
<div className={styles.overviewTab}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Order ID</span>
|
||||
<MonoText size="sm">{selectedExchange.orderId}</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}>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>
|
||||
),
|
||||
},
|
||||
]
|
||||
// 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']
|
||||
}
|
||||
}
|
||||
|
||||
// Build RouteFlow nodes from exchange processors
|
||||
const routeNodes: RouteNode[] = selectedExchange
|
||||
? selectedExchange.processors.map((p) => ({
|
||||
name: p.name,
|
||||
type: toRouteNodeType(p.type),
|
||||
durationMs: p.durationMs,
|
||||
status: p.status,
|
||||
}))
|
||||
: []
|
||||
|
||||
// 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 (
|
||||
<AppShell
|
||||
sidebar={
|
||||
@@ -294,14 +263,102 @@ export function Dashboard() {
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
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 →
|
||||
</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
|
||||
}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<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' }]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Exchange header card */
|
||||
/* ==========================================================================
|
||||
EXCHANGE HEADER CARD
|
||||
========================================================================== */
|
||||
.exchangeHeader {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -88,17 +90,85 @@
|
||||
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);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
.timelineHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -106,159 +176,255 @@
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
.timelineTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stepIndex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
.procCount {
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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-size: 10px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stepDuration {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Step body (two-column layout) */
|
||||
.stepBody {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 12px;
|
||||
.timelineToggle {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.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-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.codeBlock {
|
||||
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 {
|
||||
.count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--error);
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Error panel styles */
|
||||
.errorBadgeRow {
|
||||
display: flex;
|
||||
gap: 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-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--error-bg);
|
||||
padding: 10px 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--error-border);
|
||||
margin-bottom: 12px;
|
||||
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;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.errorDetailLabel {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import styles from './ExchangeDetail.module.css'
|
||||
|
||||
@@ -10,18 +10,21 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
// Composites
|
||||
import { ProcessorTimeline } 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
|
||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||
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 { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
||||
|
||||
// Mock data
|
||||
import { exchanges } from '../../mocks/exchanges'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
|
||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -48,8 +51,7 @@ function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'):
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Exchange body mock generator ────────────────────────────────────────────
|
||||
// For each processor step, generate a plausible exchange body snapshot
|
||||
// ─── Exchange body mock generators ──────────────────────────────────────────
|
||||
function generateExchangeSnapshot(
|
||||
step: ProcessorStep,
|
||||
orderId: string,
|
||||
@@ -65,7 +67,7 @@ function generateExchangeSnapshot(
|
||||
}
|
||||
|
||||
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',
|
||||
'CamelTimerName': step.name,
|
||||
'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 ─────────────────────────────────────────────────
|
||||
export function ExchangeDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -107,6 +164,35 @@ export function ExchangeDetail() {
|
||||
|
||||
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
|
||||
if (!exchange) {
|
||||
return (
|
||||
@@ -122,7 +208,6 @@ export function ExchangeDetail() {
|
||||
{ label: id ?? 'Unknown' },
|
||||
]}
|
||||
environment="PRODUCTION"
|
||||
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
@@ -134,6 +219,14 @@ export function ExchangeDetail() {
|
||||
|
||||
const statusVariant = statusToVariant(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 (
|
||||
<AppShell
|
||||
@@ -145,7 +238,7 @@ export function ExchangeDetail() {
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ 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 },
|
||||
]}
|
||||
environment="PRODUCTION"
|
||||
@@ -155,7 +248,7 @@ export function ExchangeDetail() {
|
||||
{/* Scrollable content */}
|
||||
<div className={styles.content}>
|
||||
|
||||
{/* Exchange header */}
|
||||
{/* Exchange header card */}
|
||||
<div className={styles.exchangeHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.headerLeft}>
|
||||
@@ -166,10 +259,10 @@ export function ExchangeDetail() {
|
||||
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
||||
</div>
|
||||
<div className={styles.exchangeRoute}>
|
||||
Route: <span className={styles.routeLink} onClick={() => navigate(`/routes/${exchange.route}`)}>{exchange.route}</span>
|
||||
<span className={styles.headerDivider}>·</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>
|
||||
Order: <MonoText size="xs">{exchange.orderId}</MonoText>
|
||||
<span className={styles.headerDivider}>·</span>
|
||||
<span className={styles.headerDivider}>·</span>
|
||||
Customer: <MonoText size="xs">{exchange.customer}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,98 +288,168 @@ export function ExchangeDetail() {
|
||||
</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 (
|
||||
<Collapsible
|
||||
key={index}
|
||||
title={
|
||||
<div className={styles.stepTitle}>
|
||||
<span className={`${styles.stepIndex} ${stepStatusClass}`}>{index + 1}</span>
|
||||
<span className={styles.stepName}>{proc.name}</span>
|
||||
<Badge
|
||||
label={proc.status.toUpperCase()}
|
||||
color={proc.status === 'fail' ? 'error' : proc.status === 'slow' ? 'warning' : 'success'}
|
||||
variant="outlined"
|
||||
/>
|
||||
<span className={styles.stepDuration}>{formatDuration(proc.durationMs)}</span>
|
||||
</div>
|
||||
}
|
||||
defaultOpen={proc.status === 'fail'}
|
||||
className={styles.stepCollapsible}
|
||||
<button
|
||||
key={ce.id}
|
||||
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) navigate(`/exchanges/${ce.id}`)
|
||||
}}
|
||||
title={`${ce.id} — ${ce.route}`}
|
||||
>
|
||||
<div className={styles.stepBody}>
|
||||
<div className={styles.stepPanel}>
|
||||
<div className={styles.stepPanelLabel}>Exchange Headers</div>
|
||||
<CodeBlock
|
||||
content={JSON.stringify(snapshot.headers, null, 2)}
|
||||
language="json"
|
||||
copyable
|
||||
className={styles.codeBlock}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.stepPanel}>
|
||||
<div className={styles.stepPanelLabel}>Exchange Body</div>
|
||||
<CodeBlock
|
||||
content={snapshot.body}
|
||||
language="json"
|
||||
copyable
|
||||
className={styles.codeBlock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<StatusDot variant={variant} />
|
||||
<span>{ce.route}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error block (if failed) */}
|
||||
{exchange.status === 'failed' && exchange.errorMessage && (
|
||||
<div className={styles.errorSection}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<span className={styles.sectionTitle}>Error Details</span>
|
||||
<Badge label="FAILED" color="error" />
|
||||
</div>
|
||||
<div className={styles.errorBody}>
|
||||
<div className={styles.errorClass}>{exchange.errorClass}</div>
|
||||
<pre className={styles.errorMessage}>{exchange.errorMessage}</pre>
|
||||
<div className={styles.errorHint}>
|
||||
Failed at processor: <MonoText size="xs">
|
||||
{exchange.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'}
|
||||
</MonoText>
|
||||
{/* Processor Timeline Section */}
|
||||
<div className={styles.timelineSection}>
|
||||
<div className={styles.timelineHeader}>
|
||||
<span className={styles.timelineTitle}>
|
||||
Processor Timeline
|
||||
<span className={styles.procCount}>{exchange.processors.length} processors</span>
|
||||
</span>
|
||||
<div className={styles.timelineToggle}>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
|
||||
onClick={() => setTimelineView('gantt')}
|
||||
>
|
||||
Timeline
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
|
||||
onClick={() => setTimelineView('flow')}
|
||||
>
|
||||
Flow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.timelineBody}>
|
||||
{timelineView === 'gantt' ? (
|
||||
<ProcessorTimeline
|
||||
processors={exchange.processors}
|
||||
totalMs={exchange.durationMs}
|
||||
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
|
||||
selectedIndex={selectedProcessorIndex}
|
||||
/>
|
||||
) : (
|
||||
<RouteFlow
|
||||
nodes={routeNodes}
|
||||
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
|
||||
selectedIndex={selectedProcessorIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processor Detail Panel (split IN / OUT) */}
|
||||
{selectedProc && snapshotIn && snapshotOut && (
|
||||
<div className={styles.detailSplit}>
|
||||
{/* Message IN */}
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowIn}>→</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}>×</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}>←</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -81,6 +81,21 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -4,10 +4,88 @@ import { PrimitivesSection } from './sections/PrimitivesSection'
|
||||
import { CompositesSection } from './sections/CompositesSection'
|
||||
import { LayoutSection } from './sections/LayoutSection'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: 'Primitives', href: '#primitives' },
|
||||
{ label: 'Composites', href: '#composites' },
|
||||
{ label: 'Layout', href: '#layout' },
|
||||
const NAV_SECTIONS = [
|
||||
{
|
||||
label: 'Primitives',
|
||||
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() {
|
||||
@@ -20,14 +98,16 @@ export function Inventory() {
|
||||
|
||||
<div className={styles.body}>
|
||||
<nav className={styles.nav} aria-label="Component categories">
|
||||
<div className={styles.navSection}>
|
||||
<span className={styles.navLabel}>Categories</span>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a key={item.href} href={item.href} className={styles.navLink}>
|
||||
{item.label}
|
||||
{NAV_SECTIONS.map((section) => (
|
||||
<div key={section.href} className={styles.navSection}>
|
||||
<span className={styles.navLabel}>{section.label}</span>
|
||||
{section.components.map((component) => (
|
||||
<a key={component.href} href={component.href} className={styles.navSubLink}>
|
||||
{component.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className={styles.content}>
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
MultiSelect,
|
||||
Popover,
|
||||
ProcessorTimeline,
|
||||
RouteFlow,
|
||||
SegmentedTabs,
|
||||
ShortcutsBar,
|
||||
Tabs,
|
||||
ToastProvider,
|
||||
@@ -227,6 +229,7 @@ export function CompositesSection() {
|
||||
{ label: 'Agents', value: 'agents', count: 6 },
|
||||
]
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [segTab, setSegTab] = useState('account')
|
||||
|
||||
// 21. TreeView
|
||||
const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1')
|
||||
@@ -605,6 +608,28 @@ export function CompositesSection() {
|
||||
</div>
|
||||
</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 */}
|
||||
<DemoCard
|
||||
id="shortcutsbar"
|
||||
@@ -636,6 +661,28 @@ export function CompositesSection() {
|
||||
</div>
|
||||
</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 */}
|
||||
<DemoCard
|
||||
id="toast"
|
||||
|
||||
@@ -76,7 +76,7 @@ export function LayoutSection() {
|
||||
>
|
||||
<div className={styles.shellDiagram}>
|
||||
<div className={styles.shellDiagramTop}>
|
||||
TopBar — breadcrumb · search · env badge · shift · user avatar
|
||||
TopBar — breadcrumb · search · filters · time range · env badge · user avatar
|
||||
</div>
|
||||
<div className={styles.shellDiagramBody}>
|
||||
<div className={styles.shellDiagramSide}>
|
||||
@@ -110,7 +110,7 @@ export function LayoutSection() {
|
||||
<DemoCard
|
||||
id="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}>
|
||||
<TopBar
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
Checkbox,
|
||||
CodeBlock,
|
||||
@@ -72,6 +73,9 @@ export function PrimitivesSection() {
|
||||
// Alert state
|
||||
const [alertDismissed, setAlertDismissed] = useState(false)
|
||||
|
||||
// ButtonGroup state
|
||||
const [bgSelection, setBgSelection] = useState<Set<string>>(new Set(['warn']))
|
||||
|
||||
// Checkbox state
|
||||
const [checked1, setChecked1] = useState(false)
|
||||
const [checked2, setChecked2] = useState(true)
|
||||
@@ -178,6 +182,24 @@ export function PrimitivesSection() {
|
||||
</div>
|
||||
</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 */}
|
||||
<DemoCard
|
||||
id="card"
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
359
src/pages/Routes/Routes.module.css
Normal file
359
src/pages/Routes/Routes.module.css
Normal 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
595
src/pages/Routes/Routes.tsx
Normal 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}`}>▲ +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}`}>▼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}`}>▲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}`}>▲28</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.kpiDetail}>
|
||||
SLA: <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}`}>↔ 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}`}>↔</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user