From 91788737b052c44960891d6388973e5853127eae Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:33:34 +0100 Subject: [PATCH] feat: add ButtonGroup, theme toggle, dark theme fixes, remove shift references - Add ButtonGroup primitive: multi-select toggle with colored dot indicators - Replace FilterPill status filters with ButtonGroup in TopBar and EventFeed - Add light/dark mode toggle to TopBar (moon/sun icon) - Fix dark theme: add --purple/--purple-bg tokens, replace all hardcoded #F3EEFA/#7C3AED with tokens, fix --amber-light text contrast in sidebar, brighten --sidebar-text/--sidebar-muted tokens, use color-mix for ProcessorTimeline bar fills - Remove all "shift" references (presets, labels, badges) - Shrink SegmentedTabs height to match search bar and ButtonGroup - Update COMPONENT_GUIDE.md with new components and updated descriptions - Add ButtonGroup demo to Inventory - Add README.md with setup instructions and navigation guide Co-Authored-By: Claude Opus 4.6 (1M context) --- COMPONENT_GUIDE.md | 30 +++-- README.md | 111 ++++++++++++++++++ .../composites/EventFeed/EventFeed.tsx | 27 ++--- .../composites/MenuItem/MenuItem.module.css | 6 +- .../ProcessorTimeline.module.css | 6 +- .../composites/RouteFlow/RouteFlow.module.css | 8 +- .../SegmentedTabs/SegmentedTabs.module.css | 14 +-- .../layout/Sidebar/Sidebar.module.css | 20 ++-- .../layout/TopBar/TopBar.module.css | 21 ++++ src/design-system/layout/TopBar/TopBar.tsx | 57 +++++---- .../ButtonGroup/ButtonGroup.module.css | 93 ++++++++------- .../primitives/ButtonGroup/ButtonGroup.tsx | 61 ++++++++-- src/design-system/primitives/index.ts | 1 + src/design-system/tokens.css | 13 +- src/design-system/utils/timePresets.ts | 6 - src/mocks/metrics.ts | 10 +- src/pages/Inventory/Inventory.tsx | 1 + .../Inventory/sections/LayoutSection.tsx | 4 +- .../Inventory/sections/PrimitivesSection.tsx | 22 ++++ src/pages/Routes/Routes.module.css | 4 +- src/pages/Routes/Routes.tsx | 4 +- 21 files changed, 361 insertions(+), 158 deletions(-) create mode 100644 README.md diff --git a/COMPONENT_GUIDE.md b/COMPONENT_GUIDE.md index e055aef..9a2988a 100644 --- a/COMPONENT_GUIDE.md +++ b/COMPONENT_GUIDE.md @@ -55,7 +55,8 @@ - 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** @@ -77,11 +78,13 @@ - 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** @@ -114,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 @@ -156,7 +161,7 @@ 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 | @@ -166,7 +171,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/ | 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 | @@ -186,7 +191,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/ | 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 | @@ -212,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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a22c89e --- /dev/null +++ b/README.md @@ -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 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 diff --git a/src/design-system/composites/EventFeed/EventFeed.tsx b/src/design-system/composites/EventFeed/EventFeed.tsx index d6e5f68..a57359e 100644 --- a/src/design-system/composites/EventFeed/EventFeed.tsx +++ b/src/design-system/composites/EventFeed/EventFeed.tsx @@ -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 @@ -140,21 +141,15 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps) )}
- {allSeverities.map((sev) => { - const count = events.filter((e) => e.severity === sev).length - return ( - toggleFilter(sev)} - /> - ) - })} + ({ + value: sev, + label: SEVERITY_LABELS[sev], + color: SEVERITY_COLORS[sev], + }))} + value={activeFilters as Set} + onChange={(next) => setActiveFilters(next as Set)} + /> {activeFilters.size > 0 && ( - {/* Status pills */} -
- {STATUS_PILLS.map(({ status, label, color }) => ( - globalFilters.toggleStatus(status)} - /> - ))} -
+ {/* Status filter group */} + { + // Sync with global filter by toggling the diff + const current = globalFilters.statusFilters + for (const v of selected) { + if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running') + } + for (const v of current) { + if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running') + } + }} + /> {/* Time range pills */} - {/* Right: env badge, user */} + {/* Right: theme toggle, env badge, user */}
+ {environment && ( {environment} )} diff --git a/src/design-system/primitives/ButtonGroup/ButtonGroup.module.css b/src/design-system/primitives/ButtonGroup/ButtonGroup.module.css index bc9e497..3c7d79d 100644 --- a/src/design-system/primitives/ButtonGroup/ButtonGroup.module.css +++ b/src/design-system/primitives/ButtonGroup/ButtonGroup.module.css @@ -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; +} diff --git a/src/design-system/primitives/ButtonGroup/ButtonGroup.tsx b/src/design-system/primitives/ButtonGroup/ButtonGroup.tsx index 98e73dc..a0e1d95 100644 --- a/src/design-system/primitives/ButtonGroup/ButtonGroup.tsx +++ b/src/design-system/primitives/ButtonGroup/ButtonGroup.tsx @@ -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 + onChange: (value: Set) => 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 ( -
- {children} +
+ {items.map((item) => { + const active = value.has(item.value) + + return ( + + ) + })}
) } diff --git a/src/design-system/primitives/index.ts b/src/design-system/primitives/index.ts index 7f5a77d..23798e3 100644 --- a/src/design-system/primitives/index.ts +++ b/src/design-system/primitives/index.ts @@ -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' diff --git a/src/design-system/tokens.css b/src/design-system/tokens.css index 7ec2f40..1782c27 100644 --- a/src/design-system/tokens.css +++ b/src/design-system/tokens.css @@ -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); diff --git a/src/design-system/utils/timePresets.ts b/src/design-system/utils/timePresets.ts index fd9895d..a2cbbdd 100644 --- a/src/design-system/utils/timePresets.ts +++ b/src/design-system/utils/timePresets.ts @@ -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 = { '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': diff --git a/src/mocks/metrics.ts b/src/mocks/metrics.ts index 6d2c58e..24dd64f 100644 --- a/src/mocks/metrics.ts +++ b/src/mocks/metrics.ts @@ -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], }, diff --git a/src/pages/Inventory/Inventory.tsx b/src/pages/Inventory/Inventory.tsx index e282b52..c4f26fd 100644 --- a/src/pages/Inventory/Inventory.tsx +++ b/src/pages/Inventory/Inventory.tsx @@ -13,6 +13,7 @@ const NAV_SECTIONS = [ { 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' }, diff --git a/src/pages/Inventory/sections/LayoutSection.tsx b/src/pages/Inventory/sections/LayoutSection.tsx index 827a7cb..76481ab 100644 --- a/src/pages/Inventory/sections/LayoutSection.tsx +++ b/src/pages/Inventory/sections/LayoutSection.tsx @@ -76,7 +76,7 @@ export function LayoutSection() { >
- TopBar — breadcrumb · search · env badge · shift · user avatar + TopBar — breadcrumb · search · filters · time range · env badge · user avatar
@@ -110,7 +110,7 @@ export function LayoutSection() {
>(new Set(['warn'])) + // Checkbox state const [checked1, setChecked1] = useState(false) const [checked2, setChecked2] = useState(true) @@ -178,6 +182,24 @@ export function PrimitivesSection() {
+ {/* 4b. ButtonGroup */} + + + + {/* 5. Card */} Total Throughput
{totalExchanges.toLocaleString()} - msg/shift + exchanges ▲ +8%
@@ -483,7 +483,7 @@ export function Routes() { Processor Performance
{processorMetrics.length} processors - +