Compare commits
48 Commits
7cd8864f2c
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9f1aa519 | ||
|
|
91788737b0 | ||
|
|
5bd965e59a | ||
|
|
e21d920fe3 | ||
|
|
a92ada8117 | ||
|
|
932dc9dcbd | ||
|
|
9c9063dc1b | ||
|
|
4f3e9c0f35 | ||
|
|
daf53ad499 | ||
|
|
8418b89a77 | ||
|
|
fdf45d0d94 | ||
|
|
d9483ec4d1 | ||
|
|
f075968e66 | ||
|
|
544b82301a | ||
|
|
4526d4c7ef | ||
|
|
646551cb93 | ||
|
|
a173c5b6ce | ||
|
|
016c92ec4f | ||
|
|
1ec7ace4e3 | ||
|
|
af3219a7df | ||
|
|
cffda9a5a7 | ||
|
|
f7d30c1257 | ||
|
|
f9addff5a6 | ||
|
|
7a49a0b1db | ||
|
|
6a404ddd53 | ||
|
|
bef93f4fe8 | ||
|
|
20a5d2030e | ||
|
|
c76ae79d7a | ||
|
|
e2db46fc98 | ||
|
|
8695b9b878 | ||
|
|
fc835ef3f9 | ||
|
|
df5450925e | ||
|
|
d41961dbe2 | ||
|
|
dd4e01d6a7 | ||
|
|
c412b3fb63 | ||
|
|
f16c5a9575 | ||
|
|
0a3d568a47 | ||
|
|
5053780dc9 | ||
|
|
92ea8673fc | ||
|
|
3be4c0a976 | ||
|
|
d1e5499688 | ||
|
|
8da0363089 | ||
|
|
5c1add8c9e | ||
|
|
cebaa2c55c | ||
|
|
2c427a31a1 | ||
|
|
727a5de9dc | ||
|
|
45c35b59fe | ||
|
|
5de97dab14 |
43
.gitea/workflows/publish.yml
Normal file
43
.gitea/workflows/publish.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Build & Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npx vitest run
|
||||||
|
|
||||||
|
- name: Build library
|
||||||
|
run: npm run build:lib
|
||||||
|
|
||||||
|
- name: Publish package
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*)
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
TAG="latest"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
|
||||||
|
TAG="dev"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
|
||||||
|
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
|
||||||
|
npm publish --tag "$TAG"
|
||||||
67
CLAUDE.md
67
CLAUDE.md
@@ -23,7 +23,7 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
|
|||||||
- No inline styles except dynamic values (width from props, etc.)
|
- No inline styles except dynamic values (width from props, etc.)
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
- `forwardRef` on all form controls (Input, Textarea, Select, Checkbox, Toggle, Label)
|
- `forwardRef` on all form controls (Input, Textarea, Select, Checkbox, Toggle, Label, FilterPill)
|
||||||
- Every component accepts a `className` prop
|
- Every component accepts a `className` prop
|
||||||
- Semantic color variants: `'success' | 'warning' | 'error'` pattern
|
- Semantic color variants: `'success' | 'warning' | 'error'` pattern
|
||||||
- Barrel exports: `src/design-system/primitives/index.ts` and `src/design-system/composites/index.ts`
|
- Barrel exports: `src/design-system/primitives/index.ts` and `src/design-system/composites/index.ts`
|
||||||
@@ -42,3 +42,68 @@ import type { Column } from '../design-system/composites'
|
|||||||
import { AppShell } from '../design-system/layout/AppShell'
|
import { AppShell } from '../design-system/layout/AppShell'
|
||||||
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Using This Design System in Other Apps
|
||||||
|
|
||||||
|
This design system is published as `@cameleer/design-system` to the Gitea npm registry.
|
||||||
|
|
||||||
|
### Registry: `https://gitea.siegeln.net/api/packages/cameleer/npm/`
|
||||||
|
|
||||||
|
### Setup in a consuming app
|
||||||
|
|
||||||
|
1. Add `.npmrc` to the project root:
|
||||||
|
|
||||||
|
```
|
||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
|
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: CI pipelines for consuming apps also need this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
|
||||||
|
|
||||||
|
2. Install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Snapshot builds (during development)
|
||||||
|
npm install @cameleer/design-system@dev
|
||||||
|
|
||||||
|
# Stable releases
|
||||||
|
npm install @cameleer/design-system
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add fonts to `index.html` (required — the package does not bundle fonts):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
Without these, `var(--font-body)` and `var(--font-mono)` fall back to `system-ui` / `monospace`.
|
||||||
|
|
||||||
|
4. Import styles once at app root, then use components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Paths (Consumer)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// All components from single entry
|
||||||
|
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
||||||
|
import { CommandPaletteProvider, useCommandPalette } from '@cameleer/design-system'
|
||||||
|
import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { hashColor } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Styles (once, at app root)
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
```
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
- Page-level attention banner → **Alert**
|
- Page-level attention banner → **Alert**
|
||||||
- Temporary non-blocking feedback → **Toast** (via `useToast`)
|
- Temporary non-blocking feedback → **Toast** (via `useToast`)
|
||||||
- Destructive action confirmation → **AlertDialog**
|
- Destructive action confirmation → **AlertDialog**
|
||||||
|
- Destructive action needing typed confirmation → **ConfirmDialog**
|
||||||
- Generic dialog with custom content → **Modal**
|
- Generic dialog with custom content → **Modal**
|
||||||
|
|
||||||
### "I need a form input"
|
### "I need a form input"
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
- Yes/no with label → **Checkbox**
|
- Yes/no with label → **Checkbox**
|
||||||
- One of N options (≤5) → **RadioGroup** + **RadioItem**
|
- One of N options (≤5) → **RadioGroup** + **RadioItem**
|
||||||
- One of N options (>5) → **Select**
|
- One of N options (>5) → **Select**
|
||||||
|
- Select multiple from a list → **MultiSelect**
|
||||||
|
- Edit text inline without a form → **InlineEdit**
|
||||||
- Date/time → **DateTimePicker**
|
- Date/time → **DateTimePicker**
|
||||||
- Date range → **DateRangePicker**
|
- Date range → **DateRangePicker**
|
||||||
- Wrap any input with label/error/hint → **FormField**
|
- Wrap any input with label/error/hint → **FormField**
|
||||||
@@ -52,12 +55,14 @@
|
|||||||
- Categorical comparison → **BarChart**
|
- Categorical comparison → **BarChart**
|
||||||
- Inline trend → **Sparkline**
|
- Inline trend → **Sparkline**
|
||||||
- Event log → **EventFeed**
|
- Event log → **EventFeed**
|
||||||
- Processing pipeline → **ProcessorTimeline**
|
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||||
|
- Processing pipeline (flow diagram) → **RouteFlow**
|
||||||
|
|
||||||
### "I need to organize content"
|
### "I need to organize content"
|
||||||
- Collapsible sections (standalone) → **Collapsible**
|
- Collapsible sections (standalone) → **Collapsible**
|
||||||
- Multiple collapsible sections (one/many open) → **Accordion**
|
- Multiple collapsible sections (one/many open) → **Accordion**
|
||||||
- Tabbed content → **Tabs**
|
- Tabbed content → **Tabs**
|
||||||
|
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||||
- Side panel inspector → **DetailPanel**
|
- Side panel inspector → **DetailPanel**
|
||||||
- Section with title + action → **SectionHeader**
|
- Section with title + action → **SectionHeader**
|
||||||
- Empty content placeholder → **EmptyState**
|
- Empty content placeholder → **EmptyState**
|
||||||
@@ -73,9 +78,15 @@
|
|||||||
- Single user avatar → **Avatar**
|
- Single user avatar → **Avatar**
|
||||||
- Stacked user avatars → **AvatarGroup**
|
- Stacked user avatars → **AvatarGroup**
|
||||||
|
|
||||||
|
### "I need to group buttons or toggle selections"
|
||||||
|
- Multi-select toggle group with colored indicators → **ButtonGroup** (e.g., status filters)
|
||||||
|
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||||
|
|
||||||
### "I need filtering"
|
### "I need filtering"
|
||||||
- Filter pill/chip → **FilterPill**
|
- Multi-select status/category filter → **ButtonGroup** (toggle items on/off)
|
||||||
|
- Filter pill/chip → **FilterPill** (individual toggleable pills)
|
||||||
- Full filter bar with search → **FilterBar**
|
- Full filter bar with search → **FilterBar**
|
||||||
|
- Select multiple from a list → **MultiSelect**
|
||||||
|
|
||||||
## Composition Patterns
|
## Composition Patterns
|
||||||
|
|
||||||
@@ -106,9 +117,11 @@ Below: charts (AreaChart, LineChart, BarChart)
|
|||||||
|
|
||||||
### Detail/inspector pattern
|
### Detail/inspector pattern
|
||||||
```
|
```
|
||||||
DetailPanel (right slide) with Tabs for sections
|
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
|
||||||
Each tab: Cards with data, CodeBlock for payloads,
|
Tabbed: use tabs prop for multiple panels
|
||||||
ProcessorTimeline for exchange flow
|
Scrollable: use children for stacked sections (overview, errors, route flow, timeline)
|
||||||
|
Each section: Cards with data, CodeBlock for payloads,
|
||||||
|
ProcessorTimeline or RouteFlow for exchange flow
|
||||||
```
|
```
|
||||||
|
|
||||||
### Feedback flow
|
### Feedback flow
|
||||||
@@ -148,37 +161,43 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| BarChart | composite | Categorical data comparison, optional stacking |
|
| BarChart | composite | Categorical data comparison, optional stacking |
|
||||||
| Breadcrumb | composite | Navigation path showing current location |
|
| Breadcrumb | composite | Navigation path showing current location |
|
||||||
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
||||||
|
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
||||||
| Card | primitive | Content container with optional accent border |
|
| Card | primitive | Content container with optional accent border |
|
||||||
| Checkbox | primitive | Boolean input with label |
|
| Checkbox | primitive | Boolean input with label |
|
||||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||||
| Collapsible | primitive | Single expand/collapse section |
|
| Collapsible | primitive | Single expand/collapse section |
|
||||||
| CommandPalette | composite | Full-screen search and command interface |
|
| CommandPalette | composite | Full-screen search and command interface |
|
||||||
| DataTable | composite | Sortable, paginated data table with row actions |
|
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||||
|
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||||
| DateRangePicker | primitive | Date range selection with presets |
|
| DateRangePicker | primitive | Date range selection with presets |
|
||||||
| DateTimePicker | primitive | Single date/time input |
|
| DateTimePicker | primitive | Single date/time input |
|
||||||
| DetailPanel | composite | Slide-in side panel with tabs |
|
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
||||||
| Dropdown | composite | Action menu triggered by any element |
|
| Dropdown | composite | Action menu triggered by any element |
|
||||||
| EmptyState | primitive | Placeholder for empty content areas |
|
| EmptyState | primitive | Placeholder for empty content areas |
|
||||||
| EventFeed | composite | Chronological event log with severity |
|
| EventFeed | composite | Chronological event log with severity |
|
||||||
| FilterBar | composite | Search + filter controls for data views |
|
| FilterBar | composite | Search + filter controls for data views |
|
||||||
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
|
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
|
||||||
| FilterPill | primitive | Individual filter chip (active/inactive) |
|
| FilterPill | primitive | Individual filter chip (active/inactive), supports forwardRef |
|
||||||
| FormField | primitive | Wrapper adding label, hint, error to any input |
|
| FormField | primitive | Wrapper adding label, hint, error to any input |
|
||||||
| InfoCallout | primitive | Inline contextual note with variant colors |
|
| InfoCallout | primitive | Inline contextual note with variant colors |
|
||||||
|
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
||||||
| Input | primitive | Single-line text input with optional icon |
|
| Input | primitive | Single-line text input with optional icon |
|
||||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
| KeyboardHint | primitive | Keyboard shortcut display |
|
||||||
| Label | primitive | Form label with optional required asterisk |
|
| Label | primitive | Form label with optional required asterisk |
|
||||||
| LineChart | composite | Time series line visualization |
|
| LineChart | composite | Time series line visualization |
|
||||||
| MenuItem | composite | Sidebar navigation item with health/count |
|
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||||
| Modal | composite | Generic dialog overlay with backdrop |
|
| Modal | composite | Generic dialog overlay with backdrop |
|
||||||
|
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
|
||||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||||
| Pagination | primitive | Page navigation controls |
|
| Pagination | primitive | Page navigation controls |
|
||||||
| Popover | composite | Click-triggered floating panel with arrow |
|
| Popover | composite | Click-triggered floating panel with arrow |
|
||||||
| ProcessorTimeline | composite | Pipeline exchange visualization |
|
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
||||||
|
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
||||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||||
| SectionHeader | primitive | Section title with optional action button |
|
| SectionHeader | primitive | Section title with optional action button |
|
||||||
|
| SegmentedTabs | composite | Pill-style segmented tab bar with sliding animated indicator. Same API as Tabs but with elevated active state. Props: tabs, active, onChange, trailing, trailingValue, className |
|
||||||
| Select | primitive | Dropdown select input |
|
| Select | primitive | Dropdown select input |
|
||||||
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
||||||
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
||||||
@@ -199,28 +218,31 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
||||||
| Sidebar | Hierarchical navigation with Applications/Agents trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
||||||
| TopBar | Header bar with breadcrumb, environment, user info |
|
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
||||||
|
|
||||||
## Import Paths
|
## Import Paths
|
||||||
|
|
||||||
|
### Within this repo (design system development)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Primitives
|
import { Button, Input, Badge } from './design-system/primitives'
|
||||||
import { Button, Input, Badge, ... } from './design-system/primitives'
|
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||||
|
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||||
// Composites
|
|
||||||
import { DataTable, Modal, Toast, ... } from './design-system/composites'
|
|
||||||
import type { Column, SearchResult, FeedEvent, ... } from './design-system/composites'
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
import { AppShell } from './design-system/layout/AppShell'
|
import { AppShell } from './design-system/layout/AppShell'
|
||||||
import { Sidebar } from './design-system/layout/Sidebar'
|
|
||||||
import { TopBar } from './design-system/layout/TopBar'
|
|
||||||
|
|
||||||
// Theme
|
|
||||||
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### From consuming apps (via npm package)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@cameleer/design-system/style.css' // once at app root
|
||||||
|
import { Button, Input, Modal, DataTable, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||||
|
|
||||||
## Styling Rules
|
## Styling Rules
|
||||||
|
|
||||||
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
||||||
|
|||||||
111
README.md
Normal file
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
|
||||||
3419
docs/superpowers/plans/2026-03-18-admin-pages.md
Normal file
3419
docs/superpowers/plans/2026-03-18-admin-pages.md
Normal file
File diff suppressed because it is too large
Load Diff
950
docs/superpowers/plans/2026-03-18-admin-redesign.md
Normal file
950
docs/superpowers/plans/2026-03-18-admin-redesign.md
Normal file
@@ -0,0 +1,950 @@
|
|||||||
|
# Admin Redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Overhaul admin section UX/UI to match the design language of the rest of the application, fix critical bugs, and improve usability.
|
||||||
|
|
||||||
|
**Architecture:** Mostly file edits — replacing custom implementations with design system composites (DataTable, Tabs), fixing tokens, reworking the user creation flow with provider awareness, and adding toast feedback + accessibility.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, TypeScript, CSS Modules, Vitest + RTL
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-18-admin-redesign.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
```
|
||||||
|
src/pages/Admin/Admin.tsx — Replace custom nav with Tabs, import Tabs
|
||||||
|
src/pages/Admin/Admin.module.css — Remove nav styles, fix padding
|
||||||
|
src/pages/Admin/AuditLog/AuditLog.tsx — Rewrite to use DataTable
|
||||||
|
src/pages/Admin/AuditLog/AuditLog.module.css — Replace with card + filter styles only
|
||||||
|
src/pages/Admin/AuditLog/auditMocks.ts — Change id to string
|
||||||
|
src/pages/Admin/OidcConfig/OidcConfig.tsx — Remove h2 title, add toolbar
|
||||||
|
src/pages/Admin/OidcConfig/OidcConfig.module.css — Remove header styles, center form
|
||||||
|
src/pages/Admin/UserManagement/UserManagement.tsx — Replace inline style
|
||||||
|
src/pages/Admin/UserManagement/UserManagement.module.css — Fix tokens, radii, shadow, add tabContent + empty/security styles
|
||||||
|
src/pages/Admin/UserManagement/UsersTab.tsx — Rework create form, add security section, toasts, accessibility, confirmations
|
||||||
|
src/pages/Admin/UserManagement/GroupsTab.tsx — Add toasts, accessibility, confirmations, empty state
|
||||||
|
src/pages/Admin/UserManagement/RolesTab.tsx — Replace emoji, add toasts, accessibility, empty state
|
||||||
|
src/pages/Admin/UserManagement/rbacMocks.ts — No changes needed (provider field already exists)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Fix critical token bug + visual polish
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/Admin.module.css`
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/UserManagement.module.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix `--bg-base` token in Admin.module.css**
|
||||||
|
|
||||||
|
In `Admin.module.css`, replace `var(--bg-base)` with `var(--bg-surface)` on line 6.
|
||||||
|
|
||||||
|
Also fix the content padding on line 35: change `padding: 20px` to `padding: 20px 24px 40px`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix `--bg-base` token and visual polish in UserManagement.module.css**
|
||||||
|
|
||||||
|
Replace both `var(--bg-base)` occurrences (lines 12, 19) with `var(--bg-surface)`.
|
||||||
|
|
||||||
|
Change `border-radius: var(--radius-md)` to `var(--radius-lg)` on `.splitPane` (line 7), `.listPane` (line 15), and `.detailPane` (line 22).
|
||||||
|
|
||||||
|
Add `box-shadow: var(--shadow-card)` to `.splitPane`.
|
||||||
|
|
||||||
|
Add these new classes at the end of the file:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.tabContent {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptySearch {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.securitySection {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.passwordDots {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetForm {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetInput {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/Admin.module.css src/pages/Admin/UserManagement/UserManagement.module.css
|
||||||
|
git commit -m "fix: replace nonexistent --bg-base token with --bg-surface, fix radii and padding"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Replace admin nav with Tabs composite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/Admin.tsx`
|
||||||
|
- Modify: `src/pages/Admin/Admin.module.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite Admin.tsx**
|
||||||
|
|
||||||
|
Replace the entire file with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||||
|
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||||
|
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||||
|
import styles from './Admin.module.css'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const ADMIN_TABS = [
|
||||||
|
{ label: 'User Management', value: '/admin/rbac' },
|
||||||
|
{ label: 'Audit Log', value: '/admin/audit' },
|
||||||
|
{ label: 'OIDC', value: '/admin/oidc' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface AdminLayoutProps {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminLayout({ title, children }: AdminLayoutProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||||
|
<TopBar
|
||||||
|
breadcrumb={[
|
||||||
|
{ label: 'Admin', href: '/admin' },
|
||||||
|
{ label: title },
|
||||||
|
]}
|
||||||
|
environment="PRODUCTION"
|
||||||
|
user={{ name: 'hendrik' }}
|
||||||
|
/>
|
||||||
|
<Tabs
|
||||||
|
tabs={ADMIN_TABS}
|
||||||
|
active={location.pathname}
|
||||||
|
onChange={(path) => navigate(path)}
|
||||||
|
/>
|
||||||
|
<div className={styles.adminContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Clean up Admin.module.css**
|
||||||
|
|
||||||
|
Remove `.adminNav`, `.adminTab`, `.adminTab:hover`, `.adminTabActive` styles entirely. Keep only `.adminContent`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/Admin.tsx src/pages/Admin/Admin.module.css
|
||||||
|
git commit -m "refactor: replace custom admin nav with Tabs composite"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Fix AuditLog mock data + migrate to DataTable
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/AuditLog/auditMocks.ts`
|
||||||
|
- Modify: `src/pages/Admin/AuditLog/AuditLog.tsx`
|
||||||
|
- Modify: `src/pages/Admin/AuditLog/AuditLog.module.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Change AuditEvent id to string in auditMocks.ts**
|
||||||
|
|
||||||
|
Change `id: number` to `id: string` in the `AuditEvent` interface.
|
||||||
|
|
||||||
|
Change all mock IDs from numbers to strings: `id: 1` → `id: 'audit-1'`, `id: 2` → `id: 'audit-2'`, etc. through all 25 events.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite AuditLog.tsx to use DataTable**
|
||||||
|
|
||||||
|
Replace the entire file with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { AdminLayout } from '../Admin'
|
||||||
|
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||||
|
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
|
||||||
|
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||||
|
import { Select } from '../../../design-system/primitives/Select/Select'
|
||||||
|
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||||
|
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
|
||||||
|
import { DataTable } from '../../../design-system/composites/DataTable/DataTable'
|
||||||
|
import type { Column } from '../../../design-system/composites/DataTable/types'
|
||||||
|
import type { DateRange } from '../../../design-system/utils/timePresets'
|
||||||
|
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
|
||||||
|
import styles from './AuditLog.module.css'
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: '', label: 'All categories' },
|
||||||
|
{ value: 'INFRA', label: 'INFRA' },
|
||||||
|
{ value: 'AUTH', label: 'AUTH' },
|
||||||
|
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
||||||
|
{ value: 'CONFIG', label: 'CONFIG' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('en-GB', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: Column<AuditEvent>[] = [
|
||||||
|
{
|
||||||
|
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
|
||||||
|
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'username', header: 'User', sortable: true,
|
||||||
|
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'category', header: 'Category', width: '110px', sortable: true,
|
||||||
|
render: (_, row) => <Badge label={row.category} color="auto" />,
|
||||||
|
},
|
||||||
|
{ key: 'action', header: 'Action' },
|
||||||
|
{
|
||||||
|
key: 'target', header: 'Target',
|
||||||
|
render: (_, row) => <span className={styles.target}>{row.target}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'result', header: 'Result', width: '90px', sortable: true,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const INITIAL_RANGE: DateRange = {
|
||||||
|
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
|
||||||
|
to: new Date(now).toISOString().slice(0, 16),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLog() {
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
|
||||||
|
const [userFilter, setUserFilter] = useState('')
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('')
|
||||||
|
const [searchFilter, setSearchFilter] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const from = new Date(dateRange.from).getTime()
|
||||||
|
const to = new Date(dateRange.to).getTime()
|
||||||
|
return AUDIT_EVENTS.filter((e) => {
|
||||||
|
const ts = new Date(e.timestamp).getTime()
|
||||||
|
if (ts < from || ts > to) return false
|
||||||
|
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
|
||||||
|
if (categoryFilter && e.category !== categoryFilter) return false
|
||||||
|
if (searchFilter) {
|
||||||
|
const q = searchFilter.toLowerCase()
|
||||||
|
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [dateRange, userFilter, categoryFilter, searchFilter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Audit Log">
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by user..."
|
||||||
|
value={userFilter}
|
||||||
|
onChange={(e) => setUserFilter(e.target.value)}
|
||||||
|
onClear={() => setUserFilter('')}
|
||||||
|
className={styles.filterInput}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={CATEGORIES}
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Search action or target..."
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(e) => setSearchFilter(e.target.value)}
|
||||||
|
onClear={() => setSearchFilter('')}
|
||||||
|
className={styles.filterInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableSection}>
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.tableTitle}>Audit Log</span>
|
||||||
|
<div className={styles.tableRight}>
|
||||||
|
<span className={styles.tableMeta}>
|
||||||
|
{filtered.length} events
|
||||||
|
</span>
|
||||||
|
<Badge label="LIVE" color="success" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={COLUMNS}
|
||||||
|
data={filtered}
|
||||||
|
sortable
|
||||||
|
flush
|
||||||
|
pageSize={10}
|
||||||
|
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
|
||||||
|
expandedContent={(row) => (
|
||||||
|
<div className={styles.expandedDetail}>
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
<div className={styles.detailField}>
|
||||||
|
<span className={styles.detailLabel}>IP Address</span>
|
||||||
|
<MonoText size="xs">{row.ipAddress}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailField}>
|
||||||
|
<span className={styles.detailLabel}>User Agent</span>
|
||||||
|
<span className={styles.detailValue}>{row.userAgent}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailField}>
|
||||||
|
<span className={styles.detailLabel}>Detail</span>
|
||||||
|
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite AuditLog.module.css**
|
||||||
|
|
||||||
|
Replace the entire file with:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterInput {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSelect {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableSection {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableMeta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandedDetail {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailValue {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify build**
|
||||||
|
|
||||||
|
Run: `npx vite build 2>&1 | tail -5`
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/AuditLog/
|
||||||
|
git commit -m "refactor: migrate AuditLog to DataTable with card wrapper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Remove duplicate titles from OidcConfig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/OidcConfig/OidcConfig.tsx`
|
||||||
|
- Modify: `src/pages/Admin/OidcConfig/OidcConfig.module.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace h2 header with toolbar in OidcConfig.tsx**
|
||||||
|
|
||||||
|
Replace the `.header` div (lines 74-84) with a compact toolbar:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update OidcConfig.module.css**
|
||||||
|
|
||||||
|
Remove `.header`, `.title`, `.headerActions`. Add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `margin: 0 auto` to the `.page` class to center the form.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/OidcConfig/
|
||||||
|
git commit -m "refactor: remove duplicate title from OIDC page, center form"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Replace inline style + fix UserManagement
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/UserManagement.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace inline style with CSS class**
|
||||||
|
|
||||||
|
Change line 20 from `<div style={{ marginTop: 16 }}>` to `<div className={styles.tabContent}>`.
|
||||||
|
|
||||||
|
Add the `styles` import if not already present (it already imports from `./UserManagement.module.css` — wait, it doesn't currently. Add:
|
||||||
|
```tsx
|
||||||
|
import styles from './UserManagement.module.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/UserManagement/UserManagement.tsx
|
||||||
|
git commit -m "refactor: replace inline style with CSS module class"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Add toasts to all RBAC tabs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add useToast to UsersTab**
|
||||||
|
|
||||||
|
Add import: `import { useToast } from '../../../design-system/composites/Toast/Toast'`
|
||||||
|
|
||||||
|
Add `const { toast } = useToast()` at the top of the `UsersTab` function body.
|
||||||
|
|
||||||
|
Add toast calls:
|
||||||
|
- After `setSelectedId(newUser.id)` in `handleCreate`: `toast({ title: 'User created', description: newUser.displayName, variant: 'success' })`
|
||||||
|
- After `setDeleteTarget(null)` in `handleDelete`: `toast({ title: 'User deleted', description: deleteTarget.username, variant: 'warning' })`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add useToast to GroupsTab**
|
||||||
|
|
||||||
|
Same pattern. Add toast calls:
|
||||||
|
- After create: `toast({ title: 'Group created', description: newGroup.name, variant: 'success' })`
|
||||||
|
- After delete: `toast({ title: 'Group deleted', description: deleteTarget.name, variant: 'warning' })`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add useToast to RolesTab**
|
||||||
|
|
||||||
|
Same pattern. Add toast calls:
|
||||||
|
- After create: `toast({ title: 'Role created', description: newRole.name, variant: 'success' })`
|
||||||
|
- After delete: `toast({ title: 'Role deleted', description: deleteTarget.name, variant: 'warning' })`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
|
||||||
|
git commit -m "feat: add toast notifications to all RBAC mutations"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Rework user creation form + password management
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||||
|
|
||||||
|
This is the largest single task. It covers spec items 3.2 (provider-aware create form), 3.3 (password management in detail pane), and 3.6 (remove unused password field).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rework the create form section**
|
||||||
|
|
||||||
|
Replace the create form state variables (lines 23-26):
|
||||||
|
```tsx
|
||||||
|
const [newUsername, setNewUsername] = useState('')
|
||||||
|
const [newDisplay, setNewDisplay] = useState('')
|
||||||
|
const [newEmail, setNewEmail] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```tsx
|
||||||
|
const [newUsername, setNewUsername] = useState('')
|
||||||
|
const [newDisplay, setNewDisplay] = useState('')
|
||||||
|
const [newEmail, setNewEmail] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local')
|
||||||
|
```
|
||||||
|
|
||||||
|
Add imports for RadioGroup, RadioItem, and InfoCallout:
|
||||||
|
```tsx
|
||||||
|
import { RadioGroup, RadioItem } from '../../../design-system/primitives/Radio/Radio'
|
||||||
|
import { InfoCallout } from '../../../design-system/primitives/InfoCallout/InfoCallout'
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `handleCreate` to use the provider selection and validate password for local:
|
||||||
|
```tsx
|
||||||
|
function handleCreate() {
|
||||||
|
if (!newUsername.trim()) return
|
||||||
|
if (newProvider === 'local' && !newPassword.trim()) return
|
||||||
|
const newUser: MockUser = {
|
||||||
|
id: `usr-${Date.now()}`,
|
||||||
|
username: newUsername.trim(),
|
||||||
|
displayName: newDisplay.trim() || newUsername.trim(),
|
||||||
|
email: newEmail.trim(),
|
||||||
|
provider: newProvider,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
directRoles: [],
|
||||||
|
directGroups: [],
|
||||||
|
}
|
||||||
|
setUsers((prev) => [...prev, newUser])
|
||||||
|
setCreating(false)
|
||||||
|
setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword(''); setNewProvider('local')
|
||||||
|
setSelectedId(newUser.id)
|
||||||
|
toast({ title: 'User created', description: newUser.displayName, variant: 'success' })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the create form JSX (lines 100-114) with:
|
||||||
|
```tsx
|
||||||
|
{creating && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
|
||||||
|
<RadioItem value="local" label="Local" />
|
||||||
|
<RadioItem value="oidc" label="OIDC" />
|
||||||
|
</RadioGroup>
|
||||||
|
<div className={styles.createFormRow}>
|
||||||
|
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
||||||
|
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
||||||
|
{newProvider === 'local' && (
|
||||||
|
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||||
|
)}
|
||||||
|
{newProvider === 'oidc' && (
|
||||||
|
<InfoCallout variant="amber">
|
||||||
|
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
|
||||||
|
</InfoCallout>
|
||||||
|
)}
|
||||||
|
<div className={styles.createFormActions}>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newUsername.trim() || (newProvider === 'local' && !newPassword.trim())}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add Security section to detail pane**
|
||||||
|
|
||||||
|
Add password reset state at the top of the component:
|
||||||
|
```tsx
|
||||||
|
const [resettingPassword, setResettingPassword] = useState(false)
|
||||||
|
const [newPw, setNewPw] = useState('')
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the Security section after the metadata grid (after the `</div>` closing the `.metaGrid`), before the "Group membership" SectionHeader:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SectionHeader>Security</SectionHeader>
|
||||||
|
<div className={styles.securitySection}>
|
||||||
|
{selected.provider === 'local' ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.securityRow}>
|
||||||
|
<span className={styles.metaLabel}>Password</span>
|
||||||
|
<span className={styles.passwordDots}>••••••••</span>
|
||||||
|
{!resettingPassword && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setResettingPassword(true); setNewPw('') }}>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{resettingPassword && (
|
||||||
|
<div className={styles.resetForm}>
|
||||||
|
<Input
|
||||||
|
placeholder="New password"
|
||||||
|
type="password"
|
||||||
|
value={newPw}
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
className={styles.resetInput}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setResettingPassword(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => { setResettingPassword(false); toast({ title: 'Password updated', description: selected.username, variant: 'success' }) }}
|
||||||
|
disabled={!newPw.trim()}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.securityRow}>
|
||||||
|
<span className={styles.metaLabel}>Authentication</span>
|
||||||
|
<span className={styles.metaValue}>OIDC ({selected.provider})</span>
|
||||||
|
</div>
|
||||||
|
<InfoCallout variant="amber">
|
||||||
|
Password managed by the identity provider.
|
||||||
|
</InfoCallout>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `npx vite build 2>&1 | tail -5`
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/UserManagement/UsersTab.tsx
|
||||||
|
git commit -m "feat: rework user creation with provider selection, add password management"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Keyboard accessibility for entity lists
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add keyboard support to UsersTab entity list**
|
||||||
|
|
||||||
|
On the `.entityList` wrapper div, add:
|
||||||
|
```tsx
|
||||||
|
<div className={styles.entityList} role="listbox" aria-label="Users">
|
||||||
|
```
|
||||||
|
|
||||||
|
On each `.entityItem` div, add `role`, `tabIndex`, `aria-selected`, and `onKeyDown`:
|
||||||
|
```tsx
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
|
||||||
|
onClick={() => setSelectedId(user.id)}
|
||||||
|
role="option"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={selectedId === user.id}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(user.id) } }}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add empty-search state after the list map:
|
||||||
|
```tsx
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className={styles.emptySearch}>No users match your search</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add keyboard support to GroupsTab entity list**
|
||||||
|
|
||||||
|
Same pattern — add `role="listbox"` to container, `role="option"` + `tabIndex={0}` + `aria-selected` + `onKeyDown` to items, and empty-search state.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add keyboard support to RolesTab entity list**
|
||||||
|
|
||||||
|
Same pattern. Also replace the lock emoji on line 113:
|
||||||
|
```tsx
|
||||||
|
{role.system && <span title="System role"> 🔒</span>}
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```tsx
|
||||||
|
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add empty-search state.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
|
||||||
|
git commit -m "feat: add keyboard accessibility and empty states to entity lists"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Add confirmation for cascading removals
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add group removal confirmation in UsersTab**
|
||||||
|
|
||||||
|
Add state for tracking the removal target:
|
||||||
|
```tsx
|
||||||
|
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the direct `onRemove` on group Tags (in the "Group membership" section) with:
|
||||||
|
```tsx
|
||||||
|
onRemove={() => {
|
||||||
|
const group = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
|
if (group && group.directRoles.length > 0) {
|
||||||
|
setRemoveGroupTarget(gId)
|
||||||
|
} else {
|
||||||
|
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== gId) })
|
||||||
|
toast({ title: 'Group removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add an AlertDialog (import from composites) for the confirmation:
|
||||||
|
```tsx
|
||||||
|
<AlertDialog
|
||||||
|
open={removeGroupTarget !== null}
|
||||||
|
onClose={() => setRemoveGroupTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (removeGroupTarget && selected) {
|
||||||
|
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== removeGroupTarget) })
|
||||||
|
toast({ title: 'Group removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
setRemoveGroupTarget(null)
|
||||||
|
}}
|
||||||
|
title="Remove group membership"
|
||||||
|
description={`Removing this group will also revoke inherited roles: ${MOCK_GROUPS.find((g) => g.id === removeGroupTarget)?.directRoles.join(', ') ?? ''}. Continue?`}
|
||||||
|
confirmLabel="Remove"
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add import: `import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add role removal confirmation in GroupsTab**
|
||||||
|
|
||||||
|
Add state:
|
||||||
|
```tsx
|
||||||
|
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace direct `onRemove` on role Tags with:
|
||||||
|
```tsx
|
||||||
|
onRemove={() => {
|
||||||
|
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
|
||||||
|
if (memberCount > 0) {
|
||||||
|
setRemoveRoleTarget(r)
|
||||||
|
} else {
|
||||||
|
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
|
||||||
|
toast({ title: 'Role removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add AlertDialog:
|
||||||
|
```tsx
|
||||||
|
<AlertDialog
|
||||||
|
open={removeRoleTarget !== null}
|
||||||
|
onClose={() => setRemoveRoleTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (removeRoleTarget && selected) {
|
||||||
|
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== removeRoleTarget) })
|
||||||
|
toast({ title: 'Role removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
setRemoveRoleTarget(null)
|
||||||
|
}}
|
||||||
|
title="Remove role from group"
|
||||||
|
description={`Removing ${removeRoleTarget} from ${selected?.name} will affect ${members.length} member(s) who inherit this role. Continue?`}
|
||||||
|
confirmLabel="Remove"
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add import: `import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx
|
||||||
|
git commit -m "feat: add confirmation dialogs for cascading removals"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Add duplicate name validation to create forms
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/UsersTab.tsx`
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/GroupsTab.tsx`
|
||||||
|
- Modify: `src/pages/Admin/UserManagement/RolesTab.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add duplicate check in UsersTab**
|
||||||
|
|
||||||
|
Add a computed `duplicateUsername`:
|
||||||
|
```tsx
|
||||||
|
const duplicateUsername = newUsername.trim() && users.some((u) => u.username.toLowerCase() === newUsername.trim().toLowerCase())
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the Create button `disabled` to include `|| duplicateUsername`.
|
||||||
|
|
||||||
|
Show error text below the username Input when duplicate:
|
||||||
|
```tsx
|
||||||
|
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add duplicate check in GroupsTab**
|
||||||
|
|
||||||
|
Similar pattern with `duplicateGroupName` check. Disable Create button when duplicate.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add duplicate check in RolesTab**
|
||||||
|
|
||||||
|
Similar pattern with `duplicateRoleName` check (compare uppercase). Disable Create button when duplicate.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Admin/UserManagement/UsersTab.tsx src/pages/Admin/UserManagement/GroupsTab.tsx src/pages/Admin/UserManagement/RolesTab.tsx
|
||||||
|
git commit -m "feat: add duplicate name validation to create forms"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Final verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: All tests pass
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the project**
|
||||||
|
|
||||||
|
Run: `npx vite build`
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix any issues**
|
||||||
|
|
||||||
|
If build fails, fix TypeScript errors. Common issues:
|
||||||
|
- Import path typos
|
||||||
|
- Missing props on components
|
||||||
|
- InfoCallout `variant` prop — check the actual prop name (may be `color` instead)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit fixes if needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: resolve build issues from admin redesign"
|
||||||
|
```
|
||||||
612
docs/superpowers/plans/2026-03-18-design-system-packaging.md
Normal file
612
docs/superpowers/plans/2026-03-18-design-system-packaging.md
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
# Design System Packaging Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
>
|
||||||
|
> **IMPORTANT: Use an isolated git worktree** — another agent may be working on the main tree. Create a worktree before starting any task. The worktree must be created from the current working state (not a clean `main`) because several provider/util files are uncommitted.
|
||||||
|
|
||||||
|
**Goal:** Package the Cameleer3 design system as `@cameleer/design-system` and publish it to Gitea's npm registry via CI/CD.
|
||||||
|
|
||||||
|
**Architecture:** Vite library mode builds the design system into an ESM bundle + CSS + TypeScript declarations. A Gitea Actions workflow publishes snapshot versions on every push to main, and stable versions on `v*` tags. Consuming apps install from Gitea's npm registry.
|
||||||
|
|
||||||
|
**Tech Stack:** Vite (library mode), vite-plugin-dts, TypeScript, CSS Modules, Gitea Actions
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-18-design-system-packaging-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before starting, ensure these currently-untracked files are committed (they are referenced by the library entry point):
|
||||||
|
|
||||||
|
- `src/design-system/providers/CommandPaletteProvider.tsx`
|
||||||
|
- `src/design-system/providers/GlobalFilterProvider.tsx`
|
||||||
|
- `src/design-system/utils/timePresets.ts`
|
||||||
|
|
||||||
|
The git remote should be added **before** creating a worktree, since remotes are repo-level config shared across all worktrees.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | File | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Create | `src/design-system/index.ts` | Library entry point — re-exports all components, imports global CSS |
|
||||||
|
| Create | `vite.lib.config.ts` | Vite library build config (separate from app build) |
|
||||||
|
| Create | `.gitea/workflows/publish.yml` | CI/CD: test, build, publish on push to main / tag |
|
||||||
|
| Modify | `.gitignore` | Add `dist/` to prevent build artifacts from being committed |
|
||||||
|
| Modify | `package.json` | Rename, add exports/peers/publishConfig/files/repository |
|
||||||
|
| Modify | `tsconfig.node.json` | Include `vite.lib.config.ts` |
|
||||||
|
| Modify | `CLAUDE.md` | Add consumer usage docs for AI agents |
|
||||||
|
| Modify | `COMPONENT_GUIDE.md` | Add package import paths for consumers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add git remote and commit untracked files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None created (git operations + staging existing untracked files)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the origin remote**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add origin https://gitea.siegeln.net/cameleer/design-system.git
|
||||||
|
```
|
||||||
|
|
||||||
|
If `origin` already exists, use `git remote set-url origin https://gitea.siegeln.net/cameleer/design-system.git` instead.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify remote**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `origin https://gitea.siegeln.net/cameleer/design-system.git (fetch)` and `(push)`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit untracked provider/util files**
|
||||||
|
|
||||||
|
These files are required by the library entry point (Task 3) but are currently untracked:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/providers/CommandPaletteProvider.tsx src/design-system/providers/GlobalFilterProvider.tsx src/design-system/utils/timePresets.ts
|
||||||
|
git commit -m "feat: add CommandPaletteProvider, GlobalFilterProvider, and timePresets"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit any other pending changes**
|
||||||
|
|
||||||
|
Check `git status` — there may be other modified files from a prior agent's work. Stage and commit anything that belongs on main before proceeding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are modified files, commit them with an appropriate message before continuing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Install vite-plugin-dts and add dist/ to .gitignore
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` (devDependencies)
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install the plugin**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D vite-plugin-dts
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `vite-plugin-dts` with `rollupTypes: true` requires `@microsoft/api-extractor` as a peer dependency. If the install warns about this, also install it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @microsoft/api-extractor
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it's in devDependencies**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).devDependencies['vite-plugin-dts'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a version string like `^4.x.x`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `dist/` to `.gitignore`**
|
||||||
|
|
||||||
|
Append to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents build artifacts from being accidentally committed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json .gitignore
|
||||||
|
git commit -m "chore: add vite-plugin-dts and ignore dist/"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create library entry point
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `src/design-system/index.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import './tokens.css'
|
||||||
|
import './reset.css'
|
||||||
|
|
||||||
|
export * from './primitives'
|
||||||
|
export * from './composites'
|
||||||
|
export * from './layout'
|
||||||
|
export * from './providers/ThemeProvider'
|
||||||
|
export * from './providers/CommandPaletteProvider'
|
||||||
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export * from './utils/hashColor'
|
||||||
|
export * from './utils/timePresets'
|
||||||
|
```
|
||||||
|
|
||||||
|
This file imports `tokens.css` and `reset.css` at the top so Vite includes them in the bundled `style.css`. Without these imports, all `var(--*)` tokens would resolve to nothing in consuming apps.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript is happy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/index.ts
|
||||||
|
git commit -m "feat: add library entry point for design system package"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Create Vite library build config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `vite.lib.config.ts`
|
||||||
|
- Modify: `tsconfig.node.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `vite.lib.config.ts`**
|
||||||
|
|
||||||
|
Note: `__dirname` works in Vite config files despite this being an ESM project — Vite transpiles config files before executing them.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import dts from 'vite-plugin-dts'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
dts({
|
||||||
|
include: ['src/design-system'],
|
||||||
|
outDir: 'dist',
|
||||||
|
rollupTypes: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
modules: {
|
||||||
|
localsConvention: 'camelCase',
|
||||||
|
generateScopedName: 'cameleer_[name]_[local]_[hash:5]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/design-system/index.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'index.es.js',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom', 'react-router-dom', 'react/jsx-runtime'],
|
||||||
|
},
|
||||||
|
cssFileName: 'style',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Key choices:
|
||||||
|
- `rollupTypes: true` consolidates all `.d.ts` into a single `dist/index.d.ts`
|
||||||
|
- `generateScopedName: 'cameleer_[name]_[local]_[hash:5]'` makes class names debuggable in consumer devtools
|
||||||
|
- `react/jsx-runtime` is externalized (peer dep of React, not bundled)
|
||||||
|
- `cssFileName: 'style'` ensures output is `dist/style.css`
|
||||||
|
- `fileName: () => 'index.es.js'` forces a deterministic output filename — Vite 6 defaults to `.mjs` for ES format which would mismatch `package.json` exports
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `tsconfig.node.json` to include the new config file**
|
||||||
|
|
||||||
|
Change the `include` array from:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"include": ["vite.config.ts", "vite.lib.config.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test the library build**
|
||||||
|
|
||||||
|
The `build:lib` script isn't in `package.json` yet (added in Task 5). Run directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vite build --config vite.lib.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `dist/` directory created with `index.es.js`, `style.css`, and `index.d.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the output**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `index.es.js`, `style.css`, `index.d.ts`
|
||||||
|
|
||||||
|
Check that `style.css` contains token variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "bg-body" dist/style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: matches found (proves tokens.css was included)
|
||||||
|
|
||||||
|
Check that type declarations contain exported components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "Button" dist/index.d.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: matches found (proves types were generated)
|
||||||
|
|
||||||
|
If `index.d.ts` is missing or empty, `rollupTypes` may have failed silently. In that case, install `@microsoft/api-extractor` and rebuild, or set `rollupTypes: false` (which produces individual `.d.ts` files — less clean but functional).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Clean up and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf dist
|
||||||
|
git add vite.lib.config.ts tsconfig.node.json
|
||||||
|
git commit -m "feat: add Vite library build config with dts generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update package.json for library publishing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update package.json**
|
||||||
|
|
||||||
|
Apply these changes to the existing `package.json`:
|
||||||
|
|
||||||
|
1. Change `"name"` from `"cameleer3"` to `"@cameleer/design-system"`
|
||||||
|
2. Change `"version"` from `"0.0.0"` to `"0.1.0"`
|
||||||
|
3. Remove `"private": true`
|
||||||
|
4. Add `"main": "./dist/index.es.js"`
|
||||||
|
5. Add `"module": "./dist/index.es.js"`
|
||||||
|
6. Add `"types": "./dist/index.d.ts"`
|
||||||
|
7. Add `"exports"` block (note: `types` must come first):
|
||||||
|
```json
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.es.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
8. Add `"files": ["dist"]`
|
||||||
|
9. Add `"sideEffects": ["*.css"]`
|
||||||
|
10. Add `"publishConfig"`:
|
||||||
|
```json
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
11. Add `"repository"`:
|
||||||
|
```json
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
12. Add `"peerDependencies"`:
|
||||||
|
```json
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
13. Add to `"scripts"`:
|
||||||
|
```json
|
||||||
|
"build:lib": "vite build --config vite.lib.config.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
The final `package.json` should look like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@cameleer/design-system",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.es.js",
|
||||||
|
"module": "./dist/index.es.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.es.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"sideEffects": ["*.css"],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"build:lib": "vite build --config vite.lib.config.ts",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"happy-dom": "^20.8.4",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vite-plugin-dts": "<version installed in Task 2>",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the full library build works end-to-end**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: succeeds, `dist/` contains `index.es.js`, `style.css`, `index.d.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Clean up and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf dist
|
||||||
|
git add package.json
|
||||||
|
git commit -m "feat: configure package.json for @cameleer/design-system publishing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create Gitea Actions workflow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.gitea/workflows/publish.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `.gitea/workflows/publish.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build & Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npx vitest run
|
||||||
|
|
||||||
|
- name: Build library
|
||||||
|
run: npm run build:lib
|
||||||
|
|
||||||
|
- name: Publish package
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
TAG="latest"
|
||||||
|
else
|
||||||
|
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
|
||||||
|
TAG="dev"
|
||||||
|
fi
|
||||||
|
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
|
||||||
|
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
|
||||||
|
npm publish --tag "$TAG"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `.npmrc` is written with `echo` commands (not a heredoc) to avoid YAML indentation being included in the file content, which would break npm's parsing.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .gitea/workflows
|
||||||
|
git add .gitea/workflows/publish.yml
|
||||||
|
git commit -m "ci: add Gitea Actions workflow for npm publishing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update documentation for consumers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
- Modify: `COMPONENT_GUIDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add consumer section to `CLAUDE.md`**
|
||||||
|
|
||||||
|
Add the following section at the end of `CLAUDE.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Using This Design System in Other Apps
|
||||||
|
|
||||||
|
This design system is published as `@cameleer/design-system` to the Gitea npm registry.
|
||||||
|
|
||||||
|
### Registry: `https://gitea.siegeln.net/api/packages/cameleer/npm/`
|
||||||
|
|
||||||
|
### Setup in a consuming app
|
||||||
|
|
||||||
|
1. Add `.npmrc` to the project root:
|
||||||
|
|
||||||
|
```
|
||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
|
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: CI pipelines for consuming apps also need this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
|
||||||
|
|
||||||
|
2. Install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Snapshot builds (during development)
|
||||||
|
npm install @cameleer/design-system@dev
|
||||||
|
|
||||||
|
# Stable releases
|
||||||
|
npm install @cameleer/design-system
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add fonts to `index.html` (required — the package does not bundle fonts):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
Without these, `var(--font-body)` and `var(--font-mono)` fall back to `system-ui` / `monospace`.
|
||||||
|
|
||||||
|
4. Import styles once at app root, then use components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Paths (Consumer)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// All components from single entry
|
||||||
|
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
||||||
|
import { CommandPaletteProvider, useCommandPalette } from '@cameleer/design-system'
|
||||||
|
import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { hashColor } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Styles (once, at app root)
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the `## Import Paths` section in `COMPONENT_GUIDE.md`**
|
||||||
|
|
||||||
|
Find the `## Import Paths` section heading and replace everything from that heading down to the next `##` heading (or end of file) with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Import Paths
|
||||||
|
|
||||||
|
### Within this repo (design system development)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button, Input, Badge } from './design-system/primitives'
|
||||||
|
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||||
|
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||||
|
import { AppShell } from './design-system/layout/AppShell'
|
||||||
|
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||||
|
```
|
||||||
|
|
||||||
|
### From consuming apps (via npm package)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@cameleer/design-system/style.css' // once at app root
|
||||||
|
import { Button, Input, Modal, DataTable, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md COMPONENT_GUIDE.md
|
||||||
|
git commit -m "docs: add consumer usage guide for @cameleer/design-system package"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Push to Gitea and verify CI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None (git operations only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Push all commits to Gitea**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Check CI status**
|
||||||
|
|
||||||
|
Use the Gitea MCP server or visit `https://gitea.siegeln.net/cameleer/design-system/actions` to monitor the workflow run. The workflow should:
|
||||||
|
1. Install deps
|
||||||
|
2. Run tests
|
||||||
|
3. Build the library
|
||||||
|
4. Publish a snapshot version with the `dev` tag
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the package is published**
|
||||||
|
|
||||||
|
Check the Gitea package registry at `https://gitea.siegeln.net/cameleer/-/packages`. The `@cameleer/design-system` package should appear with a `0.0.0-snapshot.*` version tagged as `dev`.
|
||||||
|
|
||||||
|
If the workflow fails, check the job logs via the Gitea MCP server's `actions_run_read` tool for diagnostics.
|
||||||
249
docs/superpowers/specs/2026-03-18-admin-pages-design.md
Normal file
249
docs/superpowers/specs/2026-03-18-admin-pages-design.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Admin Pages + New Components Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
**Scope:** 3 new design system components + 3 admin example pages
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Transfer the admin section from cameleer3-server UI to the design system project as example pages. Add three new reusable components to the design system that are needed by these pages and useful generally.
|
||||||
|
|
||||||
|
## New Design System Components
|
||||||
|
|
||||||
|
### 1. MultiSelect (composite)
|
||||||
|
|
||||||
|
Dropdown trigger that opens a positioned panel with searchable checkbox list and "Apply" action.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```typescript
|
||||||
|
interface MultiSelectOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiSelectProps {
|
||||||
|
options: MultiSelectOption[]
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
placeholder?: string // default: "Select..."
|
||||||
|
searchable?: boolean // default: true
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Click trigger → panel opens below with search input + checkbox list + "Apply (N)" footer
|
||||||
|
- Search filters options by label (case-insensitive)
|
||||||
|
- Checkboxes toggle selection; changes are local until "Apply" is clicked
|
||||||
|
- Apply calls `onChange` with selected values and closes panel
|
||||||
|
- Click outside or Escape closes without applying (discards pending changes)
|
||||||
|
- Trigger shows count: "2 selected" or placeholder when empty
|
||||||
|
- Arrow keys navigate checkbox list, Space toggles focused item, Tab moves between search/list/apply
|
||||||
|
- Panel has max-height with scroll for long option lists
|
||||||
|
|
||||||
|
**Accessibility:**
|
||||||
|
- Trigger: `role="combobox"`, `aria-expanded`, `aria-haspopup="listbox"`
|
||||||
|
- Option list: `role="listbox"`, options have `role="option"` with `aria-selected`
|
||||||
|
- Search input: `aria-label="Filter options"`
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- New directory: `src/design-system/composites/MultiSelect/`
|
||||||
|
- Manages its own open/close state and positioning (does NOT wrap Popover — needs controlled close behavior to distinguish apply vs. discard)
|
||||||
|
- Uses portal for the dropdown panel to avoid overflow clipping
|
||||||
|
- CSS Modules with design tokens
|
||||||
|
|
||||||
|
### 2. ConfirmDialog (composite)
|
||||||
|
|
||||||
|
Modal dialog requiring the user to type a confirmation string before a destructive action proceeds.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```typescript
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
title?: string // default: "Confirm Deletion"
|
||||||
|
message: string // e.g., "Delete user 'alice'? This cannot be undone."
|
||||||
|
confirmText: string // text the user must type to enable confirm button (must be non-empty)
|
||||||
|
confirmLabel?: string // default: "Delete"
|
||||||
|
cancelLabel?: string // default: "Cancel"
|
||||||
|
variant?: 'danger' | 'warning' | 'info' // default: 'danger'
|
||||||
|
loading?: boolean // default: false — disables buttons, shows pending state
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Built on Modal (size="sm")
|
||||||
|
- Shows title, message, text input with label "Type '{confirmText}' to confirm"
|
||||||
|
- Confirm button disabled until input matches `confirmText` exactly
|
||||||
|
- Input clears on open
|
||||||
|
- Enter submits when enabled; Escape closes
|
||||||
|
- Confirm button uses danger/warning/info variant styling (matches AlertDialog pattern)
|
||||||
|
- When `loading` is true, both buttons are disabled
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- New directory: `src/design-system/composites/ConfirmDialog/`
|
||||||
|
- Reuses Modal internally (same pattern as AlertDialog)
|
||||||
|
- Auto-focuses input on open
|
||||||
|
|
||||||
|
### 3. InlineEdit (primitive)
|
||||||
|
|
||||||
|
Click-to-edit text field that toggles between display and edit modes.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```typescript
|
||||||
|
interface InlineEditProps {
|
||||||
|
value: string
|
||||||
|
onSave: (value: string) => void
|
||||||
|
placeholder?: string // shown when value is empty in display mode
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- **Display mode:** Shows value as text with subtle edit icon (pencil). Clicking text or icon enters edit mode.
|
||||||
|
- **Edit mode:** Input field with current value. Enter saves. Escape cancels (reverts to original value). Blur cancels (same as Escape — prevents accidental saves when clicking away).
|
||||||
|
- If saved value is empty and placeholder exists, display mode shows placeholder in muted style.
|
||||||
|
- No save/cancel buttons — Enter saves, Escape/blur cancels (lightweight inline pattern).
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- New directory: `src/design-system/primitives/InlineEdit/`
|
||||||
|
- No forwardRef — the component manages its own input internally (the input only exists in edit mode, so a forwarded ref would be null in display mode)
|
||||||
|
- Manages internal editing state with useState
|
||||||
|
|
||||||
|
## Admin Pages
|
||||||
|
|
||||||
|
### Route Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/admin → redirects to /admin/rbac
|
||||||
|
/admin/audit → AuditLog page
|
||||||
|
/admin/oidc → OidcConfig page
|
||||||
|
/admin/rbac → UserManagement page (tabs: Users | Groups | Roles)
|
||||||
|
```
|
||||||
|
|
||||||
|
All pages use the standard AppShell + Sidebar + TopBar layout with breadcrumbs.
|
||||||
|
|
||||||
|
### Router Integration
|
||||||
|
|
||||||
|
Update `App.tsx` to replace the single `/admin` route with nested routes:
|
||||||
|
```tsx
|
||||||
|
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||||||
|
<Route path="/admin/audit" element={<AuditLog />} />
|
||||||
|
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||||
|
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar Integration
|
||||||
|
|
||||||
|
Keep the existing single "Admin" bottom link in the Sidebar. The admin pages handle their own sub-navigation internally via a secondary nav bar at the top of each admin page (links to Audit Log, OIDC, User Management). This avoids cluttering the main sidebar with admin sub-entries.
|
||||||
|
|
||||||
|
### Barrel Export Updates
|
||||||
|
|
||||||
|
- Add `MultiSelect` and `MultiSelectOption` type to `src/design-system/composites/index.ts`
|
||||||
|
- Add `ConfirmDialog` and `ConfirmDialogProps` type to `src/design-system/composites/index.ts`
|
||||||
|
- Add `InlineEdit` and `InlineEditProps` type to `src/design-system/primitives/index.ts`
|
||||||
|
|
||||||
|
### Page: Audit Log (`src/pages/Admin/AuditLog/`)
|
||||||
|
|
||||||
|
**Layout:** Full-width content area (no split pane).
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
1. **Header** — "Audit Log" title + event count badge
|
||||||
|
2. **Filter bar** — DateRangePicker (from/to), Input (user), Select (category: INFRA/AUTH/USER_MGMT/CONFIG), Input (search)
|
||||||
|
3. **Data table** — DataTable with columns: Timestamp (monospace), User, Category (Badge), Action, Target, Result (Badge with success/error color)
|
||||||
|
4. **Expandable rows** — Clicking a row reveals detail section with IP address, user agent, and JSON detail (CodeBlock)
|
||||||
|
5. **Pagination** — Pagination component below table
|
||||||
|
|
||||||
|
**Mock data:** ~30 audit events with varied categories, actions, results.
|
||||||
|
|
||||||
|
### Page: OIDC Config (`src/pages/Admin/OidcConfig/`)
|
||||||
|
|
||||||
|
**Layout:** Single-column form layout, max-width constrained.
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
1. **Header** — "OIDC Configuration" + Save/Test/Delete buttons
|
||||||
|
2. **Behavior** — Two Toggle fields (Enabled, Auto Sign-Up) wrapped in FormField
|
||||||
|
3. **Provider Settings** — FormField-wrapped Inputs for Issuer URI, Client ID, Client Secret (type=password)
|
||||||
|
4. **Claim Mapping** — FormField-wrapped Inputs for Roles Claim, Display Name Claim with hint text
|
||||||
|
5. **Default Roles** — Tag list (removable) + Input + Button to add new roles
|
||||||
|
6. **Delete** — Button (danger) that opens ConfirmDialog
|
||||||
|
|
||||||
|
**Mock data:** Pre-filled form state with sample OIDC config.
|
||||||
|
|
||||||
|
### Page: User Management (`src/pages/Admin/UserManagement/`)
|
||||||
|
|
||||||
|
**Layout:** Tabs component at top (Users | Groups | Roles). Each tab has a CSS grid split-pane (roughly 52/48).
|
||||||
|
|
||||||
|
#### Users Tab
|
||||||
|
- **Left pane:** Input search + "Add user" Button + scrollable user list. Each item: Avatar + name + provider Badge + meta (email, group path) + Tag list (roles: amber, groups: green, inherited: dashed Badge).
|
||||||
|
- **Inline create form:** Appears at top of list when "Add user" clicked. Input fields for username, display, email, password. Cancel/Create buttons.
|
||||||
|
- **Right pane (detail):** Large Avatar + InlineEdit (display name) + email + Delete Button. Metadata fields (Status, ID as MonoText, Created). SectionHeader "Group membership" + Tag list (removable) + MultiSelect to add groups. SectionHeader "Effective roles" + Tag list (direct: solid, inherited: dashed with source label) + MultiSelect to add roles.
|
||||||
|
- **Delete:** ConfirmDialog (type username to confirm).
|
||||||
|
|
||||||
|
#### Groups Tab
|
||||||
|
- **Left pane:** Same pattern — search + "Add group" + group list. Each item: Avatar (square) + name + meta (parent, child count, member count) + role Tags.
|
||||||
|
- **Inline create form:** Name input + parent Select (top-level or existing group).
|
||||||
|
- **Right pane:** Avatar + InlineEdit (name) + hierarchy label. Metadata (ID, Parent — editable via Select). SectionHeader "Members" + Tag list. SectionHeader "Child groups" + Tag list. SectionHeader "Assigned roles" + removable Tags + MultiSelect. SectionHeader "Hierarchy" with indented tree display.
|
||||||
|
- **Delete:** ConfirmDialog.
|
||||||
|
|
||||||
|
#### Roles Tab
|
||||||
|
- **Left pane:** Search + "Add role" + role list. Each item: Avatar (square) + name + lock icon if system + meta (description, assignment count) + Tags.
|
||||||
|
- **Inline create form:** Name, Description, Scope inputs.
|
||||||
|
- **Right pane:** Avatar + role name (non-editable for system roles) + description. Metadata (ID, Scope, Type). SectionHeader "Assigned to groups" (read-only list). SectionHeader "Assigned to users (direct)" (read-only). SectionHeader "Effective principals" with inherited entries in dashed style.
|
||||||
|
- **Delete:** ConfirmDialog (only for non-system roles).
|
||||||
|
|
||||||
|
**Mock data:** ~8 users, ~4 groups (with nesting), ~6 roles (including system roles ADMIN, USER). Realistic role/group assignments with inheritance.
|
||||||
|
|
||||||
|
### Inventory Updates
|
||||||
|
|
||||||
|
Add demos for all three new components:
|
||||||
|
|
||||||
|
- **MultiSelect** → CompositesSection: Demo showing multi-select with sample options, displaying selected count
|
||||||
|
- **InlineEdit** → PrimitivesSection: Demo showing display/edit toggle with a sample name
|
||||||
|
- **ConfirmDialog** → CompositesSection: Demo with a "Delete item" button that opens the dialog
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/design-system/composites/MultiSelect/
|
||||||
|
MultiSelect.tsx
|
||||||
|
MultiSelect.module.css
|
||||||
|
MultiSelect.test.tsx
|
||||||
|
|
||||||
|
src/design-system/primitives/InlineEdit/
|
||||||
|
InlineEdit.tsx
|
||||||
|
InlineEdit.module.css
|
||||||
|
InlineEdit.test.tsx
|
||||||
|
|
||||||
|
src/design-system/composites/ConfirmDialog/
|
||||||
|
ConfirmDialog.tsx
|
||||||
|
ConfirmDialog.module.css
|
||||||
|
ConfirmDialog.test.tsx
|
||||||
|
|
||||||
|
src/pages/Admin/
|
||||||
|
AuditLog/
|
||||||
|
AuditLog.tsx
|
||||||
|
AuditLog.module.css
|
||||||
|
auditMocks.ts
|
||||||
|
OidcConfig/
|
||||||
|
OidcConfig.tsx
|
||||||
|
OidcConfig.module.css
|
||||||
|
UserManagement/
|
||||||
|
UserManagement.tsx
|
||||||
|
UserManagement.module.css
|
||||||
|
UsersTab.tsx
|
||||||
|
GroupsTab.tsx
|
||||||
|
RolesTab.tsx
|
||||||
|
rbacMocks.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- No backend API integration (static mock data with useState)
|
||||||
|
- No persistence across page refresh
|
||||||
|
- No access control / role gating in the example app
|
||||||
|
- Dashboard tab from RBAC is excluded per user request
|
||||||
|
- Split pane is page-local CSS, not a design system component
|
||||||
207
docs/superpowers/specs/2026-03-18-admin-redesign.md
Normal file
207
docs/superpowers/specs/2026-03-18-admin-redesign.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Admin Section Redesign Spec
|
||||||
|
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
**Scope:** UX/UI consistency overhaul of AuditLog, OidcConfig, UserManagement admin pages
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three expert reviews identified critical bugs, consistency gaps, and usability issues in the admin section. This spec covers all changes organized by priority tier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1: Critical Bugs
|
||||||
|
|
||||||
|
### 1.1 Replace nonexistent `--bg-base` token
|
||||||
|
|
||||||
|
`--bg-base` is referenced 3 times but does not exist in `tokens.css`. Dark mode is broken.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `Admin.module.css` line 6: `.adminNav`
|
||||||
|
- `UserManagement.module.css` lines 13, 19: `.listPane`, `.detailPane`
|
||||||
|
|
||||||
|
**Fix:** Replace all `var(--bg-base)` with `var(--bg-surface)`.
|
||||||
|
|
||||||
|
### 1.2 Change AuditEvent `id` to string
|
||||||
|
|
||||||
|
`DataTable` requires `T extends { id: string }`. Current `AuditEvent.id` is `number`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `auditMocks.ts`: change `id: number` to `id: string`, update all IDs to `'audit-1'`, `'audit-2'`, etc.
|
||||||
|
- `AuditLog.tsx`: update `expandedId` state from `number | null` to `string | null`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2: High-Impact Consistency
|
||||||
|
|
||||||
|
### 2.1 Replace admin nav with Tabs composite
|
||||||
|
|
||||||
|
The hand-rolled admin nav in `Admin.tsx` lacks ARIA roles and has subtle color differences from the Tabs composite.
|
||||||
|
|
||||||
|
**Fix:** Replace the custom `<nav>` block with:
|
||||||
|
```tsx
|
||||||
|
<Tabs
|
||||||
|
tabs={[
|
||||||
|
{ label: 'User Management', value: '/admin/rbac' },
|
||||||
|
{ label: 'Audit Log', value: '/admin/audit' },
|
||||||
|
{ label: 'OIDC', value: '/admin/oidc' },
|
||||||
|
]}
|
||||||
|
active={location.pathname}
|
||||||
|
onChange={(path) => navigate(path)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete `.adminNav`, `.adminTab`, `.adminTabActive` from `Admin.module.css`.
|
||||||
|
|
||||||
|
### 2.2 Remove duplicate page titles
|
||||||
|
|
||||||
|
Breadcrumb + active tab + h2 heading all show the same label. Remove the h2.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `AuditLog.tsx`: remove the `.header` div with `<h2>Audit Log</h2>`. Move the event count badge into a toolbar row.
|
||||||
|
- `OidcConfig.tsx`: remove the `.header` div with `<h2>`. Keep Save/Test buttons in a compact toolbar row below the tabs.
|
||||||
|
|
||||||
|
Delete `.header`, `.title` CSS from both `AuditLog.module.css` and `OidcConfig.module.css`.
|
||||||
|
|
||||||
|
### 2.3 Migrate AuditLog to DataTable
|
||||||
|
|
||||||
|
Replace the hand-built `<table>` with the DataTable composite.
|
||||||
|
|
||||||
|
**Column definitions:**
|
||||||
|
- Timestamp: render with `MonoText`, width `'170px'`
|
||||||
|
- User: render with bold text
|
||||||
|
- Category: render with `Badge color="auto"`
|
||||||
|
- Action: plain text
|
||||||
|
- Target: render with ellipsis style
|
||||||
|
- Result: render with `Badge color={row.result === 'SUCCESS' ? 'success' : 'error'}`
|
||||||
|
|
||||||
|
**Features to enable:**
|
||||||
|
- `sortable` on Timestamp, User, Category, Result columns
|
||||||
|
- `rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}` — red left-border on failures
|
||||||
|
- `expandedContent={(row) => <detail block with IP, user agent, CodeBlock>}`
|
||||||
|
- `pageSize={10}`
|
||||||
|
- `flush` prop (table sits inside a card wrapper)
|
||||||
|
|
||||||
|
**Card wrapper:** Wrap DataTable in a section with `background: var(--bg-surface)`, `border: 1px solid var(--border-subtle)`, `border-radius: var(--radius-lg)`, `box-shadow: var(--shadow-card)`. Add a header row with title + event count badge, matching Dashboard's `.tableSection` pattern.
|
||||||
|
|
||||||
|
**Delete from AuditLog.module.css:** `.tableWrap`, `.table`, `.th`, `.row`, `.td`, `.userCell`, `.target`, `.empty`, `.detailRow`, `.detailCell`, `.detailGrid`, `.detailField`, `.detailLabel`, `.detailValue`, `.detailJson`. Also remove the separate `Pagination` import — DataTable handles pagination internally.
|
||||||
|
|
||||||
|
### 2.4 Fix content padding
|
||||||
|
|
||||||
|
`Admin.module.css` `.adminContent`: change `padding: 20px` to `padding: 20px 24px 40px`.
|
||||||
|
|
||||||
|
### 2.5 Center OIDC form
|
||||||
|
|
||||||
|
`OidcConfig.module.css` `.page`: add `margin: 0 auto` to center the 640px max-width form.
|
||||||
|
|
||||||
|
### 2.6 Replace inline style
|
||||||
|
|
||||||
|
`UserManagement.tsx` line 20: replace `style={{ marginTop: 16 }}` with a CSS class `.tabContent { margin-top: 16px; }` in `UserManagement.module.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3: Usability Improvements
|
||||||
|
|
||||||
|
### 3.1 Add toast notifications to RBAC mutations
|
||||||
|
|
||||||
|
Import `useToast` into `UsersTab.tsx`, `GroupsTab.tsx`, `RolesTab.tsx`. Fire toasts on:
|
||||||
|
- Create user/group/role: `variant: 'success'`
|
||||||
|
- Delete user/group/role: `variant: 'warning'`
|
||||||
|
- Role assigned/removed: `variant: 'success'`
|
||||||
|
- Group added/removed: `variant: 'success'`
|
||||||
|
|
||||||
|
### 3.2 Rework user creation form
|
||||||
|
|
||||||
|
Replace the flat inline form with a provider-aware two-step form.
|
||||||
|
|
||||||
|
**Form structure:**
|
||||||
|
1. **Provider selection** — RadioGroup with "Local" and "OIDC" options. Default: "Local".
|
||||||
|
2. **Fields (always shown):** Username (required), Display name (optional), Email (optional)
|
||||||
|
3. **Fields (Local only):** Password (required)
|
||||||
|
4. **OIDC info callout:** When OIDC selected, show an InfoCallout: "OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login."
|
||||||
|
|
||||||
|
**Components used:** RadioGroup + RadioItem (existing primitive), Input, InfoCallout (existing primitive), Button.
|
||||||
|
|
||||||
|
**Create handler:** Set `provider` based on RadioGroup selection. Only validate password when provider is 'local'.
|
||||||
|
|
||||||
|
The form should use the existing inline pattern (appears at the top of the list pane), but use a proper card-like treatment (the existing `.createForm` background is fine).
|
||||||
|
|
||||||
|
### 3.3 Add password management to user detail pane
|
||||||
|
|
||||||
|
Add a "Security" section (using `SectionHeader`) in the user detail pane, below the metadata grid.
|
||||||
|
|
||||||
|
**For local users:**
|
||||||
|
- Show "Password: ••••••••" with a "Reset password" Button
|
||||||
|
- Clicking "Reset password" reveals an inline form: Input (type=password, placeholder "New password") + Cancel/Set buttons
|
||||||
|
- Setting fires a success toast: "Password updated"
|
||||||
|
|
||||||
|
**For OIDC users:**
|
||||||
|
- Show "Authentication: OIDC ({provider})"
|
||||||
|
- Show InfoCallout: "Password managed by the identity provider."
|
||||||
|
- No password reset option
|
||||||
|
|
||||||
|
### 3.4 Add ConfirmDialog to cascading removals
|
||||||
|
|
||||||
|
When removing a group from a user (which may strip inherited roles), show a confirmation dialog if the group grants roles.
|
||||||
|
|
||||||
|
When removing a role from a group (which affects all members), show a confirmation dialog.
|
||||||
|
|
||||||
|
Direct role removal from a user does not need confirmation (low risk).
|
||||||
|
|
||||||
|
### 3.5 Make entity list items keyboard accessible
|
||||||
|
|
||||||
|
Add to each `.entityItem` div:
|
||||||
|
- `role="option"`
|
||||||
|
- `tabIndex={0}`
|
||||||
|
- `aria-selected={selectedId === item.id}`
|
||||||
|
- `onKeyDown`: Enter/Space to select, ArrowUp/ArrowDown to navigate
|
||||||
|
|
||||||
|
Add `role="listbox"` and `aria-label` to each `.entityList` container.
|
||||||
|
|
||||||
|
### 3.6 Add expand/collapse affordance to AuditLog
|
||||||
|
|
||||||
|
After DataTable migration (2.3), add a first column with a chevron indicator (`>` / `v`) that rotates when the row is expanded. Width: `'40px'`. This makes the expandable row pattern discoverable.
|
||||||
|
|
||||||
|
### 3.7 Add duplicate name validation
|
||||||
|
|
||||||
|
Before creating, check for existing names:
|
||||||
|
- Users: `users.some(u => u.username === newUsername.trim())`
|
||||||
|
- Groups: `groups.some(g => g.name.toLowerCase() === newName.trim().toLowerCase())`
|
||||||
|
- Roles: `roles.some(r => r.name === newName.trim().toUpperCase())`
|
||||||
|
|
||||||
|
Show inline error using state + red text below the name field. Disable Create button.
|
||||||
|
|
||||||
|
### 3.8 Partial FilterBar migration for AuditLog
|
||||||
|
|
||||||
|
After DataTable migration, use FilterBar for search + category filters:
|
||||||
|
- Search input maps to FilterBar's built-in search
|
||||||
|
- Categories (INFRA, AUTH, USER_MGMT, CONFIG) become FilterPill toggles
|
||||||
|
- Keep DateRangePicker and user filter Input alongside FilterBar in a row
|
||||||
|
|
||||||
|
### 3.9 Add empty-search states to entity lists
|
||||||
|
|
||||||
|
When search returns no results in Users/Groups/Roles lists, show centered muted text: "No users match your search" (etc.) inside the `.entityList` area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 4: Polish
|
||||||
|
|
||||||
|
### 4.1 Replace lock emoji with Badge
|
||||||
|
|
||||||
|
`RolesTab.tsx`: replace `🔒` with `<Badge label="system" color="auto" variant="outlined" />`.
|
||||||
|
|
||||||
|
### 4.2 Fix split-pane border radius
|
||||||
|
|
||||||
|
`UserManagement.module.css`: change `border-radius: var(--radius-md)` to `var(--radius-lg)` on `.splitPane`, `.listPane`, `.detailPane`.
|
||||||
|
|
||||||
|
### 4.3 Add shadow to split-pane
|
||||||
|
|
||||||
|
`UserManagement.module.css`: add `box-shadow: var(--shadow-card)` to `.splitPane`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Replacing split-pane with DataTable+DetailPanel (not appropriate for dense editing)
|
||||||
|
- EventFeed as alternative audit view (future enhancement)
|
||||||
|
- Tabs inside user detail pane (not needed until more sections are added)
|
||||||
|
- FilterBar extension to support DateRangePicker slots (separate design system ticket)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Design System Packaging — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
**Status:** Approved
|
||||||
|
**Package:** `@cameleer/design-system`
|
||||||
|
**Registry:** Gitea npm registry at `gitea.siegeln.net`
|
||||||
|
**Repository:** `https://gitea.siegeln.net/cameleer/design-system`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Package the Cameleer3 design system as a reusable npm library so other React applications in the Cameleer ecosystem can consume it via `npm install`. Publishing is automated via Gitea Actions.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Registry | Gitea built-in npm registry | Already have Gitea infrastructure |
|
||||||
|
| Package scope | `@cameleer/design-system` | Matches the org |
|
||||||
|
| Export style | Single package, flat exports | Simple DX, tree-shaking handles unused code |
|
||||||
|
| What's included | Everything (primitives, composites, layout, providers, utils, tokens) | All consuming apps are Cameleer apps |
|
||||||
|
| Build tool | Vite library mode | Already using Vite, CSS Modules first-class |
|
||||||
|
| Output format | ESM only | All consumers are Vite/ESM |
|
||||||
|
| Versioning | Tag-based releases + snapshot on every main push | Snapshots for dev, tags for milestones |
|
||||||
|
| Runner arch | ARM64 | Gitea runner is ARM64 |
|
||||||
|
| Auth secret | `REGISTRY_TOKEN` (org-level) | Existing all-access token |
|
||||||
|
|
||||||
|
## 1. Library Entry Point
|
||||||
|
|
||||||
|
New file `src/design-system/index.ts` — the single public API. It must import global CSS at the top so that `tokens.css` and `reset.css` are included in the bundled `dist/style.css`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import './tokens.css'
|
||||||
|
import './reset.css'
|
||||||
|
|
||||||
|
export * from './primitives'
|
||||||
|
export * from './composites'
|
||||||
|
export * from './layout'
|
||||||
|
export * from './providers/ThemeProvider'
|
||||||
|
export * from './providers/CommandPaletteProvider'
|
||||||
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export * from './utils/hashColor'
|
||||||
|
export * from './utils/timePresets'
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the CSS imports, all `var(--*)` tokens used in component CSS Modules would resolve to nothing in consuming apps.
|
||||||
|
|
||||||
|
Consumers import the bundled CSS once at their app root:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Vite Library Build Config
|
||||||
|
|
||||||
|
A separate `vite.lib.config.ts` to keep library and app builds independent:
|
||||||
|
|
||||||
|
- **Entry:** `src/design-system/index.ts`
|
||||||
|
- **Output:** `dist/index.es.js` (ESM)
|
||||||
|
- **CSS:** Extracted to `dist/style.css`
|
||||||
|
- **CSS Modules scoping:** `cameleer_[name]_[local]_[hash:5]` — debuggable in consumer devtools, unique enough to avoid collisions
|
||||||
|
- **Externals:** `react`, `react-dom`, `react-router-dom` (peer deps, not bundled)
|
||||||
|
- **Types:** `vite-plugin-dts` generates `dist/index.d.ts` with full TypeScript declarations
|
||||||
|
- **Build script:** `"build:lib": "vite build --config vite.lib.config.ts"`
|
||||||
|
- **New devDependency:** `vite-plugin-dts` must be installed
|
||||||
|
|
||||||
|
`tsconfig.node.json` must be updated to include `vite.lib.config.ts`.
|
||||||
|
|
||||||
|
Output structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
index.es.js # ESM bundle
|
||||||
|
style.css # All CSS (tokens + reset + component modules)
|
||||||
|
index.d.ts # TypeScript declarations
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Package Configuration
|
||||||
|
|
||||||
|
Updates to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@cameleer/design-system",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.es.js",
|
||||||
|
"module": "./dist/index.es.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.es.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"sideEffects": ["*.css"],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `"private": true` is removed
|
||||||
|
- `"types"` comes first in the exports conditions (TypeScript resolution requirement)
|
||||||
|
- `publishConfig` ensures `npm publish` targets the Gitea registry, not npmjs.org
|
||||||
|
- Existing `scripts`, `dependencies`, and `devDependencies` remain for the app build
|
||||||
|
- `peerDependencies` tells consumers what to provide
|
||||||
|
|
||||||
|
## 4. Gitea Actions CI/CD Pipeline
|
||||||
|
|
||||||
|
Workflow at `.gitea/workflows/publish.yml`:
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Push to `main` → publish snapshot (`0.0.0-snapshot.<YYYYMMDD>.<short-sha>`) with `dev` dist-tag
|
||||||
|
- Push tag `v*` → publish stable release (e.g., `1.0.0`) with `latest` dist-tag
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Checkout at ref
|
||||||
|
2. `npm ci` (install deps)
|
||||||
|
3. `npx vitest run` (gate: don't publish broken code)
|
||||||
|
4. `npm run build:lib` (build the library)
|
||||||
|
5. Determine version from tag or generate snapshot version
|
||||||
|
6. Configure `.npmrc` with scoped registry + auth token
|
||||||
|
7. `npm publish --tag <dev|latest>`
|
||||||
|
|
||||||
|
**Runner:** ARM64 with `node:22-bookworm-slim` container image.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build & Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx vitest run
|
||||||
|
- run: npm run build:lib
|
||||||
|
- run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
TAG="latest"
|
||||||
|
else
|
||||||
|
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
|
||||||
|
TAG="dev"
|
||||||
|
fi
|
||||||
|
cat > .npmrc << 'NPMRC'
|
||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
|
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
NPMRC
|
||||||
|
npm publish --tag "$TAG"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Consumer Setup
|
||||||
|
|
||||||
|
In any consuming app (e.g., `cameleer3-server/ui`):
|
||||||
|
|
||||||
|
**1. Add `.npmrc` to project root:**
|
||||||
|
|
||||||
|
```
|
||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
|
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The consuming app's CI pipeline also needs this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
|
||||||
|
|
||||||
|
**2. Install:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# During development (snapshot builds)
|
||||||
|
npm install @cameleer/design-system@dev
|
||||||
|
|
||||||
|
# For stable releases (later)
|
||||||
|
npm install @cameleer/design-system
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Add fonts to `index.html`:**
|
||||||
|
|
||||||
|
The design system uses DM Sans and JetBrains Mono via Google Fonts. These must be loaded by the consuming app since font `<link>` tags are not part of the library output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
Without these, `var(--font-body)` and `var(--font-mono)` will fall back to `system-ui` / `monospace`.
|
||||||
|
|
||||||
|
**4. Use:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Import styles once at app root
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
|
||||||
|
// Import components
|
||||||
|
import { Button, Input, Modal, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Documentation Updates
|
||||||
|
|
||||||
|
Update `CLAUDE.md` and `COMPONENT_GUIDE.md` in this repo with:
|
||||||
|
|
||||||
|
- The package name and registry URL
|
||||||
|
- How consuming apps should configure `.npmrc` (including CI)
|
||||||
|
- Font loading requirement (Google Fonts link)
|
||||||
|
- Import patterns for consumers (`@cameleer/design-system` instead of relative paths)
|
||||||
|
- Note that `style.css` must be imported once at the app root
|
||||||
|
|
||||||
|
This ensures other AI agents working on consuming Cameleer apps understand how to use the design system.
|
||||||
967
package-lock.json
generated
967
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -1,11 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "cameleer3",
|
"name": "@cameleer/design-system",
|
||||||
"private": true,
|
"version": "0.1.0",
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "./dist/index.es.js",
|
||||||
|
"module": "./dist/index.es.js",
|
||||||
|
"types": "./dist/index.es.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.es.d.ts",
|
||||||
|
"import": "./dist/index.es.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"sideEffects": ["*.css"],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:lib": "vite build --config vite.lib.config.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
@@ -15,6 +34,11 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
@@ -25,6 +49,7 @@
|
|||||||
"happy-dom": "^20.8.4",
|
"happy-dom": "^20.8.4",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
src/App.tsx
95
src/App.tsx
@@ -1,26 +1,107 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { useMemo, useCallback } from 'react'
|
||||||
|
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||||||
import { Dashboard } from './pages/Dashboard/Dashboard'
|
import { Dashboard } from './pages/Dashboard/Dashboard'
|
||||||
import { Metrics } from './pages/Metrics/Metrics'
|
import { Routes as RoutesPage } from './pages/Routes/Routes'
|
||||||
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
|
||||||
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
|
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
|
||||||
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
|
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
|
||||||
|
import { AgentInstance } from './pages/AgentInstance/AgentInstance'
|
||||||
import { Inventory } from './pages/Inventory/Inventory'
|
import { Inventory } from './pages/Inventory/Inventory'
|
||||||
import { Admin } from './pages/Admin/Admin'
|
import { AuditLog } from './pages/Admin/AuditLog/AuditLog'
|
||||||
|
import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig'
|
||||||
|
import { UserManagement } from './pages/Admin/UserManagement/UserManagement'
|
||||||
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
|
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
|
||||||
|
|
||||||
|
import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette'
|
||||||
|
import type { SearchResult } from './design-system/composites/CommandPalette/types'
|
||||||
|
import { useCommandPalette } from './design-system/providers/CommandPaletteProvider'
|
||||||
|
import { useGlobalFilters } from './design-system/providers/GlobalFilterProvider'
|
||||||
|
import { buildSearchData } from './mocks/searchData'
|
||||||
|
import { exchanges } from './mocks/exchanges'
|
||||||
|
import { routes } from './mocks/routes'
|
||||||
|
import { agents } from './mocks/agents'
|
||||||
|
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
|
||||||
|
|
||||||
|
const routeToApp = buildRouteToAppMap()
|
||||||
|
|
||||||
|
/** Compute which sidebar path to reveal for a given search result */
|
||||||
|
function computeSidebarRevealPath(result: SearchResult): string | undefined {
|
||||||
|
if (!result.path) return undefined
|
||||||
|
|
||||||
|
if (result.category === 'application') {
|
||||||
|
return result.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.category === 'route') {
|
||||||
|
return result.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.category === 'agent') {
|
||||||
|
return result.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.category === 'exchange') {
|
||||||
|
const exchange = exchanges.find((e) => e.id === result.id)
|
||||||
|
if (exchange) {
|
||||||
|
const appId = routeToApp.get(exchange.route)
|
||||||
|
if (appId) return `/apps/${appId}/${exchange.route}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.path
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { open: paletteOpen, setOpen } = useCommandPalette()
|
||||||
|
const { isInTimeRange, statusFilters } = useGlobalFilters()
|
||||||
|
|
||||||
|
const filteredSearchData = useMemo(() => {
|
||||||
|
// Filter exchanges by time range and status
|
||||||
|
let filteredExchanges = exchanges.filter((e) => isInTimeRange(e.timestamp))
|
||||||
|
if (statusFilters.size > 0) {
|
||||||
|
filteredExchanges = filteredExchanges.filter((e) => statusFilters.has(e.status))
|
||||||
|
}
|
||||||
|
return buildSearchData(filteredExchanges, routes, agents)
|
||||||
|
}, [isInTimeRange, statusFilters])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
if (result.path) {
|
||||||
|
const sidebarReveal = computeSidebarRevealPath(result)
|
||||||
|
navigate(result.path, { state: sidebarReveal ? { sidebarReveal } : undefined })
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
[navigate, setOpen],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||||
<Route path="/apps" element={<Dashboard />} />
|
<Route path="/apps" element={<Dashboard />} />
|
||||||
<Route path="/apps/:id" element={<Dashboard />} />
|
<Route path="/apps/:id" element={<Dashboard />} />
|
||||||
<Route path="/metrics" element={<Metrics />} />
|
<Route path="/apps/:id/:routeId" element={<Dashboard />} />
|
||||||
<Route path="/routes/:id" element={<RouteDetail />} />
|
<Route path="/routes" element={<RoutesPage />} />
|
||||||
|
<Route path="/routes/:appId" element={<RoutesPage />} />
|
||||||
|
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
|
||||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||||
|
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
|
||||||
<Route path="/agents/*" element={<AgentHealth />} />
|
<Route path="/agents/*" element={<AgentHealth />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||||||
|
<Route path="/admin/audit" element={<AuditLog />} />
|
||||||
|
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||||
|
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||||
<Route path="/api-docs" element={<ApiDocs />} />
|
<Route path="/api-docs" element={<ApiDocs />} />
|
||||||
<Route path="/inventory" element={<Inventory />} />
|
<Route path="/inventory" element={<Inventory />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<CommandPalette
|
||||||
|
open={paletteOpen}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onOpen={() => setOpen(true)}
|
||||||
|
data={filteredSearchData}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface CommandPaletteProps {
|
|||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
route: 'Routes',
|
route: 'Routes',
|
||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
@@ -23,6 +24,7 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
|||||||
|
|
||||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||||
'all',
|
'all',
|
||||||
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
'route',
|
'route',
|
||||||
'agent',
|
'agent',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'exchange' | 'route' | 'agent'
|
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -10,6 +10,7 @@ export interface SearchResult {
|
|||||||
meta: string
|
meta: string
|
||||||
timestamp?: string
|
timestamp?: string
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
|
path?: string
|
||||||
expandedContent?: string
|
expandedContent?: string
|
||||||
matchRanges?: [number, number][]
|
matchRanges?: [number, number][]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { ConfirmDialog } from './ConfirmDialog'
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onConfirm: vi.fn(),
|
||||||
|
message: 'Delete user "alice"? This cannot be undone.',
|
||||||
|
confirmText: 'alice',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfirmDialog', () => {
|
||||||
|
it('renders title and message when open', () => {
|
||||||
|
render(<ConfirmDialog {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Confirm Deletion')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Delete user "alice"? This cannot be undone.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<ConfirmDialog {...defaultProps} open={false} />)
|
||||||
|
expect(screen.queryByText('Confirm Deletion')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders custom title', () => {
|
||||||
|
render(<ConfirmDialog {...defaultProps} title="Remove item" />)
|
||||||
|
expect(screen.getByText('Remove item')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows confirm instruction text', () => {
|
||||||
|
render(<ConfirmDialog {...defaultProps} />)
|
||||||
|
expect(screen.getByText(/Type "alice" to confirm/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables confirm button until text matches', () => {
|
||||||
|
render(<ConfirmDialog {...defaultProps} />)
|
||||||
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables confirm button when text matches', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ConfirmDialog {...defaultProps} />)
|
||||||
|
await user.type(screen.getByRole('textbox'), 'alice')
|
||||||
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm when confirm button is clicked after typing', async () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||||||
|
await user.type(screen.getByRole('textbox'), 'alice')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when cancel button is clicked', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ConfirmDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm on Enter when text matches', async () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||||||
|
await user.type(screen.getByRole('textbox'), 'alice')
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onConfirm on Enter when text does not match', async () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||||||
|
await user.type(screen.getByRole('textbox'), 'alic')
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables both buttons when loading', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ConfirmDialog {...defaultProps} loading />)
|
||||||
|
await user.type(screen.getByRole('textbox'), 'alice')
|
||||||
|
const buttons = screen.getAllByRole('button')
|
||||||
|
for (const btn of buttons) {
|
||||||
|
expect(btn).toBeDisabled()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears input when opened', async () => {
|
||||||
|
const { rerender } = render(<ConfirmDialog {...defaultProps} open={false} />)
|
||||||
|
rerender(<ConfirmDialog {...defaultProps} open={true} />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-focuses input on open', async () => {
|
||||||
|
render(<ConfirmDialog {...defaultProps} />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders custom button labels', () => {
|
||||||
|
render(<ConfirmDialog {...defaultProps} confirmLabel="Remove" cancelLabel="Keep" />)
|
||||||
|
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
97
src/design-system/composites/ConfirmDialog/ConfirmDialog.tsx
Normal file
97
src/design-system/composites/ConfirmDialog/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Modal } from '../Modal/Modal'
|
||||||
|
import { Button } from '../../primitives/Button/Button'
|
||||||
|
import styles from './ConfirmDialog.module.css'
|
||||||
|
|
||||||
|
export interface ConfirmDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
title?: string
|
||||||
|
message: string
|
||||||
|
confirmText: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
variant?: 'danger' | 'warning' | 'info'
|
||||||
|
loading?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title = 'Confirm Deletion',
|
||||||
|
message,
|
||||||
|
confirmText,
|
||||||
|
confirmLabel = 'Delete',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
variant = 'danger',
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const matches = input === confirmText
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setInput('')
|
||||||
|
const id = setTimeout(() => inputRef.current?.focus(), 0)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && matches && !loading) {
|
||||||
|
e.preventDefault()
|
||||||
|
onConfirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmButtonVariant = variant === 'danger' ? 'danger' : 'primary'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h2 className={styles.title}>{title}</h2>
|
||||||
|
<p className={styles.message}>{message}</p>
|
||||||
|
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.label} htmlFor="confirm-input">
|
||||||
|
{`Type "${confirmText}" to confirm`}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="confirm-input"
|
||||||
|
className={styles.input}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttonRow}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmButtonVariant}
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!matches || loading}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flush {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
pageSizeOptions = [10, 25, 50, 100],
|
pageSizeOptions = [10, 25, 50, 100],
|
||||||
rowAccent,
|
rowAccent,
|
||||||
expandedContent,
|
expandedContent,
|
||||||
|
flush = false,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||||
@@ -73,7 +74,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
|
||||||
<div className={styles.scroll}>
|
<div className={styles.scroll}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ export interface DataTableProps<T extends { id: string }> {
|
|||||||
pageSizeOptions?: number[]
|
pageSizeOptions?: number[]
|
||||||
rowAccent?: (row: T) => 'error' | 'warning' | undefined
|
rowAccent?: (row: T) => 'error' | 'warning' | undefined
|
||||||
expandedContent?: (row: T) => ReactNode | null
|
expandedContent?: (row: T) => ReactNode | null
|
||||||
|
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||||
|
flush?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,16 @@ interface DetailPanelProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
title: string
|
title: string
|
||||||
tabs: Tab[]
|
tabs?: Tab[]
|
||||||
|
children?: ReactNode
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailPanel({ open, onClose, title, tabs, actions, className }: DetailPanelProps) {
|
export function DetailPanel({ open, onClose, title, tabs, children, actions, className }: DetailPanelProps) {
|
||||||
const [activeTab, setActiveTab] = useState(tabs[0]?.value ?? '')
|
const [activeTab, setActiveTab] = useState(tabs?.[0]?.value ?? '')
|
||||||
|
|
||||||
const activeContent = tabs.find((t) => t.value === activeTab)?.content
|
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -38,7 +39,7 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tabs.length > 0 && (
|
{tabs && tabs.length > 0 && (
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -54,7 +55,7 @@ export function DetailPanel({ open, onClose, title, tabs, actions, className }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{activeContent}
|
{children ?? activeContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actions && (
|
{actions && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import styles from './EventFeed.module.css'
|
import styles from './EventFeed.module.css'
|
||||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
|
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
|
|
||||||
export interface FeedEvent {
|
export interface FeedEvent {
|
||||||
id: string
|
id: string
|
||||||
@@ -53,6 +54,13 @@ const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
|||||||
running: '\u2699', // ⚙
|
running: '\u2699', // ⚙
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
||||||
|
error: 'var(--error)',
|
||||||
|
warning: 'var(--warning)',
|
||||||
|
success: 'var(--success)',
|
||||||
|
running: 'var(--running)',
|
||||||
|
}
|
||||||
|
|
||||||
const SEVERITY_LABELS: Record<SeverityFilter, string> = {
|
const SEVERITY_LABELS: Record<SeverityFilter, string> = {
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
warning: 'Warning',
|
warning: 'Warning',
|
||||||
@@ -133,18 +141,15 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.filters}>
|
<div className={styles.filters}>
|
||||||
{allSeverities.map((sev) => {
|
<ButtonGroup
|
||||||
const count = events.filter((e) => e.severity === sev).length
|
items={allSeverities.map((sev): ButtonGroupItem => ({
|
||||||
return (
|
value: sev,
|
||||||
<FilterPill
|
label: SEVERITY_LABELS[sev],
|
||||||
key={sev}
|
color: SEVERITY_COLORS[sev],
|
||||||
label={SEVERITY_LABELS[sev]}
|
}))}
|
||||||
count={count}
|
value={activeFilters as Set<string>}
|
||||||
active={activeFilters.has(sev)}
|
onChange={(next) => setActiveFilters(next as Set<SeverityFilter>)}
|
||||||
onClick={() => toggleFilter(sev)}
|
|
||||||
/>
|
/>
|
||||||
)
|
|
||||||
})}
|
|
||||||
{activeFilters.size > 0 && (
|
{activeFilters.size > 0 && (
|
||||||
<button
|
<button
|
||||||
className={styles.clearBtn}
|
className={styles.clearBtn}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@
|
|||||||
|
|
||||||
.item:hover {
|
.item:hover {
|
||||||
background: var(--sidebar-hover);
|
background: var(--sidebar-hover);
|
||||||
color: #E8DFD4;
|
color: var(--sidebar-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item.active {
|
.item.active {
|
||||||
background: var(--sidebar-active);
|
background: var(--sidebar-active);
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
border-left-color: var(--amber);
|
border-left-color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,5 +69,5 @@
|
|||||||
|
|
||||||
.item.active .count {
|
.item.active .count {
|
||||||
background: rgba(198, 130, 14, 0.2);
|
background: rgba(198, 130, 14, 0.2);
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|||||||
148
src/design-system/composites/MultiSelect/MultiSelect.module.css
Normal file
148
src/design-system/composites/MultiSelect/MultiSelect.module.css
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:focus-visible {
|
||||||
|
border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger[aria-disabled="true"] {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerText {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerPlaceholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: panelIn 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panelIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionList {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
accent-color: var(--amber);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionLabel {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn {
|
||||||
|
padding: 4px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--bg-base);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn:hover {
|
||||||
|
background: var(--amber-hover);
|
||||||
|
}
|
||||||
109
src/design-system/composites/MultiSelect/MultiSelect.test.tsx
Normal file
109
src/design-system/composites/MultiSelect/MultiSelect.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MultiSelect } from './MultiSelect'
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ value: 'admin', label: 'ADMIN' },
|
||||||
|
{ value: 'editor', label: 'EDITOR' },
|
||||||
|
{ value: 'viewer', label: 'VIEWER' },
|
||||||
|
{ value: 'operator', label: 'OPERATOR' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('MultiSelect', () => {
|
||||||
|
it('renders trigger with placeholder', () => {
|
||||||
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Select...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders trigger with custom placeholder', () => {
|
||||||
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} placeholder="Add roles..." />)
|
||||||
|
expect(screen.getByText('Add roles...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows selected count on trigger', () => {
|
||||||
|
render(<MultiSelect options={OPTIONS} value={['admin', 'editor']} onChange={vi.fn()} />)
|
||||||
|
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dropdown on trigger click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('combobox'))
|
||||||
|
expect(screen.getByText('ADMIN')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('EDITOR')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows checkboxes for pre-selected values', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('combobox'))
|
||||||
|
const adminCheckbox = screen.getByRole('checkbox', { name: 'ADMIN' })
|
||||||
|
expect(adminCheckbox).toBeChecked()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters options by search text', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('combobox'))
|
||||||
|
await user.type(screen.getByPlaceholderText('Search...'), 'adm')
|
||||||
|
expect(screen.getByText('ADMIN')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('EDITOR')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onChange with selected values on Apply', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
|
||||||
|
await user.click(screen.getByRole('combobox'))
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'VIEWER' }))
|
||||||
|
await user.click(screen.getByRole('button', { name: /Apply/ }))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(['admin', 'viewer'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('discards pending changes on Escape', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
|
||||||
|
await user.click(screen.getByRole('combobox'))
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
expect(onChange).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes dropdown on outside click without applying', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<div>
|
||||||
|
<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />
|
||||||
|
<button>Outside</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
await user.click(screen.getByRole('combobox'))
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||||||
|
await user.click(screen.getByText('Outside'))
|
||||||
|
expect(onChange).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables trigger when disabled prop is set', () => {
|
||||||
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} disabled />)
|
||||||
|
expect(screen.getByRole('combobox')).toHaveAttribute('aria-disabled', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides search input when searchable is false', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} searchable={false} />)
|
||||||
|
await user.click(screen.getByRole('combobox'))
|
||||||
|
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Apply button with count of pending changes', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('combobox'))
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'EDITOR' }))
|
||||||
|
expect(screen.getByRole('button', { name: /Apply \(2\)/ })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
162
src/design-system/composites/MultiSelect/MultiSelect.tsx
Normal file
162
src/design-system/composites/MultiSelect/MultiSelect.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import styles from './MultiSelect.module.css'
|
||||||
|
|
||||||
|
export interface MultiSelectOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiSelectProps {
|
||||||
|
options: MultiSelectOption[]
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
searchable?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelect({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select...',
|
||||||
|
searchable = true,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
}: MultiSelectProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [pending, setPending] = useState<string[]>(value)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||||
|
|
||||||
|
// Sync pending with value when opening
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPending(value)
|
||||||
|
setSearch('')
|
||||||
|
}
|
||||||
|
}, [open, value])
|
||||||
|
|
||||||
|
// Position the panel below the trigger
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
|
setPos({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
panelRef.current && !panelRef.current.contains(e.target as Node) &&
|
||||||
|
triggerRef.current && !triggerRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function toggleOption(optValue: string) {
|
||||||
|
setPending((prev) =>
|
||||||
|
prev.includes(optValue) ? prev.filter((v) => v !== optValue) : [...prev, optValue]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApply() {
|
||||||
|
onChange(pending)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = options.filter((opt) =>
|
||||||
|
opt.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const triggerLabel = value.length > 0 ? `${value.length} selected` : placeholder
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.wrap} ${className ?? ''}`}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
className={styles.trigger}
|
||||||
|
onClick={() => !disabled && setOpen(!open)}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-disabled={disabled}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={value.length > 0 ? styles.triggerText : styles.triggerPlaceholder}>
|
||||||
|
{triggerLabel}
|
||||||
|
</span>
|
||||||
|
<span className={styles.chevron} aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className={styles.panel}
|
||||||
|
style={{ top: pos.top, left: pos.left, width: Math.max(pos.width, 200) }}
|
||||||
|
>
|
||||||
|
{searchable && (
|
||||||
|
<input
|
||||||
|
className={styles.search}
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={styles.optionList} role="listbox">
|
||||||
|
{filtered.map((opt) => (
|
||||||
|
<label key={opt.value} className={styles.option} role="option" aria-selected={pending.includes(opt.value)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={styles.checkbox}
|
||||||
|
checked={pending.includes(opt.value)}
|
||||||
|
onChange={() => toggleOption(opt.value)}
|
||||||
|
aria-label={opt.label}
|
||||||
|
/>
|
||||||
|
<span className={styles.optionLabel}>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className={styles.empty}>No matches</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<button
|
||||||
|
className={styles.applyBtn}
|
||||||
|
onClick={handleApply}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Apply ({pending.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -69,15 +69,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ok {
|
.ok {
|
||||||
background: rgba(61, 124, 71, 0.5);
|
background: color-mix(in srgb, var(--success) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slow {
|
.slow {
|
||||||
background: rgba(194, 117, 22, 0.5);
|
background: color-mix(in srgb, var(--warning) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fail {
|
.fail {
|
||||||
background: rgba(192, 57, 43, 0.5);
|
background: color-mix(in srgb, var(--error) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dur {
|
.dur {
|
||||||
@@ -89,6 +89,13 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selectedRow {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
border-left: 3px solid var(--amber);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px 0 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export interface ProcessorStep {
|
|||||||
interface ProcessorTimelineProps {
|
interface ProcessorTimelineProps {
|
||||||
processors: ProcessorStep[]
|
processors: ProcessorStep[]
|
||||||
totalMs: number
|
totalMs: number
|
||||||
onProcessorClick?: (processor: ProcessorStep) => void
|
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||||
|
selectedIndex?: number
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export function ProcessorTimeline({
|
|||||||
processors,
|
processors,
|
||||||
totalMs,
|
totalMs,
|
||||||
onProcessorClick,
|
onProcessorClick,
|
||||||
|
selectedIndex,
|
||||||
className,
|
className,
|
||||||
}: ProcessorTimelineProps) {
|
}: ProcessorTimelineProps) {
|
||||||
const safeTotal = totalMs || 1
|
const safeTotal = totalMs || 1
|
||||||
@@ -49,17 +51,19 @@ export function ProcessorTimeline({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
|
|
||||||
|
const isSelected = selectedIndex === i
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`${styles.row} ${onProcessorClick ? styles.clickable : ''}`}
|
className={`${styles.row} ${onProcessorClick ? styles.clickable : ''} ${isSelected ? styles.selectedRow : ''}`}
|
||||||
onClick={() => onProcessorClick?.(proc)}
|
onClick={() => onProcessorClick?.(proc, i)}
|
||||||
role={onProcessorClick ? 'button' : undefined}
|
role={onProcessorClick ? 'button' : undefined}
|
||||||
tabIndex={onProcessorClick ? 0 : undefined}
|
tabIndex={onProcessorClick ? 0 : undefined}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (onProcessorClick && (e.key === 'Enter' || e.key === ' ')) {
|
if (onProcessorClick && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onProcessorClick(proc)
|
onProcessorClick(proc, i)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={`${proc.name}: ${formatDuration(proc.durationMs)} (${proc.status})`}
|
aria-label={`${proc.name}: ${formatDuration(proc.durationMs)} (${proc.status})`}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ export { BarChart } from './BarChart/BarChart'
|
|||||||
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
|
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
|
||||||
export { CommandPalette } from './CommandPalette/CommandPalette'
|
export { CommandPalette } from './CommandPalette/CommandPalette'
|
||||||
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
|
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
|
||||||
|
export { ConfirmDialog } from './ConfirmDialog/ConfirmDialog'
|
||||||
|
export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog'
|
||||||
export { DataTable } from './DataTable/DataTable'
|
export { DataTable } from './DataTable/DataTable'
|
||||||
export type { Column, DataTableProps } from './DataTable/types'
|
export type { Column, DataTableProps } from './DataTable/types'
|
||||||
export { DetailPanel } from './DetailPanel/DetailPanel'
|
export { DetailPanel } from './DetailPanel/DetailPanel'
|
||||||
@@ -17,10 +19,15 @@ export { FilterBar } from './FilterBar/FilterBar'
|
|||||||
export { LineChart } from './LineChart/LineChart'
|
export { LineChart } from './LineChart/LineChart'
|
||||||
export { MenuItem } from './MenuItem/MenuItem'
|
export { MenuItem } from './MenuItem/MenuItem'
|
||||||
export { Modal } from './Modal/Modal'
|
export { Modal } from './Modal/Modal'
|
||||||
|
export { MultiSelect } from './MultiSelect/MultiSelect'
|
||||||
|
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||||
export { Popover } from './Popover/Popover'
|
export { Popover } from './Popover/Popover'
|
||||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
|
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||||
|
export type { RouteNode } from './RouteFlow/RouteFlow'
|
||||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||||
|
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||||
export { Tabs } from './Tabs/Tabs'
|
export { Tabs } from './Tabs/Tabs'
|
||||||
export { ToastProvider, useToast } from './Toast/Toast'
|
export { ToastProvider, useToast } from './Toast/Toast'
|
||||||
export { TreeView } from './TreeView/TreeView'
|
export { TreeView } from './TreeView/TreeView'
|
||||||
|
|||||||
11
src/design-system/index.ts
Normal file
11
src/design-system/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import './tokens.css'
|
||||||
|
import './reset.css'
|
||||||
|
|
||||||
|
export * from './primitives'
|
||||||
|
export * from './composites'
|
||||||
|
export * from './layout'
|
||||||
|
export * from './providers/ThemeProvider'
|
||||||
|
export * from './providers/CommandPaletteProvider'
|
||||||
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export * from './utils/hashColor'
|
||||||
|
export * from './utils/timePresets'
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
.logoImg {
|
.logoImg {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
filter: brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%);
|
filter: brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
|
|
||||||
.item.active {
|
.item.active {
|
||||||
background: var(--sidebar-active);
|
background: var(--sidebar-active);
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
border-left-color: var(--amber);
|
border-left-color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item.active .navIcon {
|
.item.active .navIcon {
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.routeArrow {
|
.routeArrow {
|
||||||
@@ -197,8 +197,9 @@
|
|||||||
/* ── SidebarTree styles ──────────────────────────────────────────────────── */
|
/* ── SidebarTree styles ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.treeSection {
|
.treeSection {
|
||||||
padding: 0 6px;
|
padding: 0 6px 6px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 2px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionLabel {
|
.treeSectionLabel {
|
||||||
@@ -214,9 +215,9 @@
|
|||||||
.treeSectionToggle {
|
.treeSectionToggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px 4px;
|
padding: 8px 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionChevronBtn {
|
.treeSectionChevronBtn {
|
||||||
@@ -248,11 +249,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionLabel:hover {
|
.treeSectionLabel:hover {
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionLabelActive {
|
.treeSectionLabelActive {
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree {
|
.tree {
|
||||||
@@ -289,13 +290,13 @@
|
|||||||
|
|
||||||
.treeRowActive {
|
.treeRowActive {
|
||||||
background: var(--sidebar-active);
|
background: var(--sidebar-active);
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
border-left-color: var(--amber);
|
border-left-color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeRowActive .treeBadge {
|
.treeRowActive .treeBadge {
|
||||||
background: rgba(198, 130, 14, 0.2);
|
background: rgba(198, 130, 14, 0.2);
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chevron */
|
/* Chevron */
|
||||||
@@ -379,7 +380,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.treeStar:hover {
|
.treeStar:hover {
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Starred section ─────────────────────────────────────────────────────── */
|
/* ── Starred section ─────────────────────────────────────────────────────── */
|
||||||
@@ -499,7 +500,7 @@
|
|||||||
|
|
||||||
.bottomItemActive {
|
.bottomItemActive {
|
||||||
background: var(--sidebar-active);
|
background: var(--sidebar-active);
|
||||||
color: var(--amber-light);
|
color: var(--amber);
|
||||||
border-left-color: var(--amber);
|
border-left-color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ describe('Sidebar', () => {
|
|||||||
expect(screen.getByText('Agents')).toBeInTheDocument()
|
expect(screen.getByText('Agents')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders Metrics nav link', () => {
|
it('renders Routes nav link', () => {
|
||||||
renderSidebar()
|
renderSidebar()
|
||||||
expect(screen.getByText('Metrics')).toBeInTheDocument()
|
expect(screen.getByText('Routes')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders bottom links', () => {
|
it('renders bottom links', () => {
|
||||||
@@ -87,9 +87,9 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
it('renders app names in the Applications tree', () => {
|
it('renders app names in the Applications tree', () => {
|
||||||
renderSidebar()
|
renderSidebar()
|
||||||
// order-service appears in both Applications and Agents trees
|
// order-service appears in Applications, Routes, and Agents trees
|
||||||
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
||||||
expect(screen.getByText('payment-svc')).toBeInTheDocument()
|
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders exchange count badges', () => {
|
it('renders exchange count badges', () => {
|
||||||
@@ -130,8 +130,8 @@ describe('Sidebar', () => {
|
|||||||
const searchInput = screen.getByPlaceholderText('Filter...')
|
const searchInput = screen.getByPlaceholderText('Filter...')
|
||||||
await user.type(searchInput, 'payment')
|
await user.type(searchInput, 'payment')
|
||||||
|
|
||||||
// payment-svc should still be visible
|
// payment-svc should still be visible (may appear in multiple trees)
|
||||||
expect(screen.getByText('payment-svc')).toBeInTheDocument()
|
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('expands tree to show children when chevron is clicked', async () => {
|
it('expands tree to show children when chevron is clicked', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||||
@@ -57,7 +57,30 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|||||||
label: route.name,
|
label: route.name,
|
||||||
icon: <span className={styles.routeArrow}>▸</span>,
|
icon: <span className={styles.routeArrow}>▸</span>,
|
||||||
badge: formatCount(route.exchangeCount),
|
badge: formatCount(route.exchangeCount),
|
||||||
path: `/routes/${route.id}`,
|
path: `/apps/${app.id}/${route.id}`,
|
||||||
|
starrable: true,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||||
|
return apps
|
||||||
|
.filter((app) => app.routes.length > 0)
|
||||||
|
.map((app) => ({
|
||||||
|
id: `routes:${app.id}`,
|
||||||
|
label: app.name,
|
||||||
|
icon: <StatusDot variant={app.health} />,
|
||||||
|
badge: `${app.routes.length} routes`,
|
||||||
|
path: `/routes/${app.id}`,
|
||||||
|
starrable: true,
|
||||||
|
starKey: `routes:${app.id}`,
|
||||||
|
children: app.routes.map((route) => ({
|
||||||
|
id: `routestat:${app.id}:${route.id}`,
|
||||||
|
starKey: `routes:${app.id}:${route.id}`,
|
||||||
|
label: route.name,
|
||||||
|
icon: <span className={styles.routeArrow}>▸</span>,
|
||||||
|
badge: formatCount(route.exchangeCount),
|
||||||
|
path: `/routes/${app.id}/${route.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
@@ -95,7 +118,7 @@ interface StarredItem {
|
|||||||
label: string
|
label: string
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactNode
|
||||||
path: string
|
path: string
|
||||||
type: 'application' | 'route' | 'agent'
|
type: 'application' | 'route' | 'agent' | 'routestat'
|
||||||
parentApp?: string
|
parentApp?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,24 +141,57 @@ function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): Starr
|
|||||||
items.push({
|
items.push({
|
||||||
starKey: key,
|
starKey: key,
|
||||||
label: route.name,
|
label: route.name,
|
||||||
path: `/routes/${route.id}`,
|
path: `/apps/${app.id}/${route.id}`,
|
||||||
type: 'route',
|
type: 'route',
|
||||||
parentApp: app.name,
|
parentApp: app.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const agentsAppKey = `agents:${app.id}`
|
||||||
|
if (starredIds.has(agentsAppKey)) {
|
||||||
|
items.push({
|
||||||
|
starKey: agentsAppKey,
|
||||||
|
label: app.name,
|
||||||
|
icon: <StatusDot variant={app.health} />,
|
||||||
|
path: `/agents/${app.id}`,
|
||||||
|
type: 'agent',
|
||||||
|
})
|
||||||
|
}
|
||||||
for (const agent of app.agents) {
|
for (const agent of app.agents) {
|
||||||
const key = `${app.id}:${agent.id}`
|
const key = `${app.id}:${agent.id}`
|
||||||
if (starredIds.has(key)) {
|
if (starredIds.has(key)) {
|
||||||
items.push({
|
items.push({
|
||||||
starKey: key,
|
starKey: key,
|
||||||
label: agent.name,
|
label: agent.name,
|
||||||
path: `/agents/${agent.id}`,
|
path: `/agents/${app.id}/${agent.id}`,
|
||||||
type: 'agent',
|
type: 'agent',
|
||||||
parentApp: app.name,
|
parentApp: app.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Routes tree starred items
|
||||||
|
const routesAppKey = `routes:${app.id}`
|
||||||
|
if (starredIds.has(routesAppKey)) {
|
||||||
|
items.push({
|
||||||
|
starKey: routesAppKey,
|
||||||
|
label: app.name,
|
||||||
|
icon: <StatusDot variant={app.health} />,
|
||||||
|
path: `/routes/${app.id}`,
|
||||||
|
type: 'routestat',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const route of app.routes) {
|
||||||
|
const routeKey = `routes:${app.id}:${route.id}`
|
||||||
|
if (starredIds.has(routeKey)) {
|
||||||
|
items.push({
|
||||||
|
starKey: routeKey,
|
||||||
|
label: route.name,
|
||||||
|
path: `/routes/${app.id}/${route.id}`,
|
||||||
|
type: 'routestat',
|
||||||
|
parentApp: app.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
@@ -196,6 +252,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||||
|
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
|
||||||
|
|
||||||
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
|
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
|
||||||
_setAppsCollapsed((prev) => {
|
_setAppsCollapsed((prev) => {
|
||||||
@@ -212,6 +269,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
|
||||||
|
_setRoutesCollapsed((prev) => {
|
||||||
|
const next = updater(prev)
|
||||||
|
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
@@ -219,6 +284,32 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
// Build tree data
|
// Build tree data
|
||||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||||
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
||||||
|
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
|
||||||
|
|
||||||
|
// Sidebar reveal from Cmd-K navigation (passed via location state)
|
||||||
|
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarRevealPath) return
|
||||||
|
|
||||||
|
// Uncollapse Applications section if reveal path matches an apps tree node
|
||||||
|
const matchesAppTree = appNodes.some((node) =>
|
||||||
|
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||||
|
)
|
||||||
|
if (matchesAppTree && appsCollapsed) {
|
||||||
|
_setAppsCollapsed(false)
|
||||||
|
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncollapse Agents section if reveal path matches an agents tree node
|
||||||
|
const matchesAgentTree = agentNodes.some((node) =>
|
||||||
|
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||||
|
)
|
||||||
|
if (matchesAgentTree && agentsCollapsed) {
|
||||||
|
_setAgentsCollapsed(false)
|
||||||
|
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
|
||||||
|
}
|
||||||
|
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Build starred items
|
// Build starred items
|
||||||
const starredItems = useMemo(
|
const starredItems = useMemo(
|
||||||
@@ -229,8 +320,15 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
const starredApps = starredItems.filter((i) => i.type === 'application')
|
const starredApps = starredItems.filter((i) => i.type === 'application')
|
||||||
const starredRoutes = starredItems.filter((i) => i.type === 'route')
|
const starredRoutes = starredItems.filter((i) => i.type === 'route')
|
||||||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||||||
|
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
|
||||||
const hasStarred = starredItems.length > 0
|
const hasStarred = starredItems.length > 0
|
||||||
|
|
||||||
|
// For exchange detail pages, use the reveal path for sidebar selection so
|
||||||
|
// the parent route is highlighted (exchanges have no sidebar entry of their own)
|
||||||
|
const effectiveSelectedPath = location.pathname.startsWith('/exchanges/') && sidebarRevealPath
|
||||||
|
? sidebarRevealPath
|
||||||
|
: location.pathname
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -299,11 +397,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
{!appsCollapsed && (
|
{!appsCollapsed && (
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={appNodes}
|
nodes={appNodes}
|
||||||
selectedPath={location.pathname}
|
selectedPath={effectiveSelectedPath}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
onToggleStar={toggleStar}
|
onToggleStar={toggleStar}
|
||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:apps"
|
persistKey="cameleer:expanded:apps"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -332,32 +431,48 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
{!agentsCollapsed && (
|
{!agentsCollapsed && (
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={agentNodes}
|
nodes={agentNodes}
|
||||||
selectedPath={location.pathname}
|
selectedPath={effectiveSelectedPath}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
onToggleStar={toggleStar}
|
onToggleStar={toggleStar}
|
||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:agents"
|
persistKey="cameleer:expanded:agents"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flat nav links */}
|
{/* Routes tree (collapsible, label navigates to /routes) */}
|
||||||
<div className={styles.items}>
|
<div className={styles.treeSection}>
|
||||||
<div
|
<div className={styles.treeSectionToggle}>
|
||||||
className={[
|
<button
|
||||||
styles.item,
|
className={styles.treeSectionChevronBtn}
|
||||||
location.pathname === '/metrics' ? styles.active : '',
|
onClick={() => setRoutesCollapsed((v) => !v)}
|
||||||
].filter(Boolean).join(' ')}
|
aria-expanded={!routesCollapsed}
|
||||||
onClick={() => navigate('/metrics')}
|
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||||
|
>
|
||||||
|
{routesCollapsed ? '▸' : '▾'}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||||
|
onClick={() => navigate('/routes')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
||||||
>
|
>
|
||||||
<span className={styles.navIcon}>▤</span>
|
Routes
|
||||||
<div className={styles.itemInfo}>
|
</span>
|
||||||
<div className={styles.itemName}>Metrics</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!routesCollapsed && (
|
||||||
|
<SidebarTree
|
||||||
|
nodes={routeNodes}
|
||||||
|
selectedPath={effectiveSelectedPath}
|
||||||
|
isStarred={isStarred}
|
||||||
|
onToggleStar={toggleStar}
|
||||||
|
filterQuery={search}
|
||||||
|
persistKey="cameleer:expanded:routes"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No results message */}
|
{/* No results message */}
|
||||||
@@ -396,6 +511,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{starredRouteStats.length > 0 && (
|
||||||
|
<StarredGroup
|
||||||
|
label="Routes"
|
||||||
|
items={starredRouteStats}
|
||||||
|
onNavigate={navigate}
|
||||||
|
onRemove={toggleStar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -406,7 +529,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname === '/admin' ? styles.bottomItemActive : '',
|
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => navigate('/admin')}
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
@@ -31,6 +32,7 @@ export interface SidebarTreeProps {
|
|||||||
className?: string
|
className?: string
|
||||||
filterQuery?: string
|
filterQuery?: string
|
||||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||||
|
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
||||||
@@ -138,6 +140,7 @@ export function SidebarTree({
|
|||||||
className,
|
className,
|
||||||
filterQuery,
|
filterQuery,
|
||||||
persistKey,
|
persistKey,
|
||||||
|
autoRevealPath,
|
||||||
}: SidebarTreeProps) {
|
}: SidebarTreeProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -146,6 +149,27 @@ export function SidebarTree({
|
|||||||
() => persistKey ? readExpandState(persistKey) : new Set(),
|
() => persistKey ? readExpandState(persistKey) : new Set(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Auto-expand parent when autoRevealPath changes (e.g. from Cmd-K navigation)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRevealPath) return
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Check if a child of this node matches the reveal path
|
||||||
|
if (node.children?.some((child) => child.path === autoRevealPath)) {
|
||||||
|
if (!userExpandedIds.has(node.id)) {
|
||||||
|
setUserExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(node.id)
|
||||||
|
if (persistKey) writeExpandState(persistKey, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Also check if the node itself matches (top-level node, no parent to expand)
|
||||||
|
if (node.path === autoRevealPath) break
|
||||||
|
}
|
||||||
|
}, [autoRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
const { filtered, matchedParentIds } = useMemo(
|
const { filtered, matchedParentIds } = useMemo(
|
||||||
() => filterNodes(nodes, filterQuery ?? ''),
|
() => filterNodes(nodes, filterQuery ?? ''),
|
||||||
|
|||||||
@@ -14,7 +14,15 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Center search trigger */
|
/* Filters group: time range + status pills */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search trigger */
|
||||||
.search {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -28,9 +36,9 @@
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
min-width: 280px;
|
width: 200px;
|
||||||
flex: 1;
|
flex-shrink: 1;
|
||||||
max-width: 400px;
|
min-width: 120px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +81,27 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.themeToggle {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeToggle:hover {
|
||||||
|
color: var(--amber);
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
.env {
|
.env {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -86,16 +115,6 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shift {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--running-bg);
|
|
||||||
color: var(--running);
|
|
||||||
border: 1px solid var(--running-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user {
|
.user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
import { Avatar } from '../../primitives/Avatar/Avatar'
|
import { Avatar } from '../../primitives/Avatar/Avatar'
|
||||||
|
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
|
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
|
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
|
||||||
|
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
||||||
|
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||||
|
import { useTheme } from '../../providers/ThemeProvider'
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -10,29 +16,36 @@ interface BreadcrumbItem {
|
|||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
environment?: string
|
environment?: string
|
||||||
shift?: string
|
|
||||||
user?: { name: string }
|
user?: { name: string }
|
||||||
onSearchClick?: () => void
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_ITEMS: ButtonGroupItem[] = [
|
||||||
|
{ value: 'completed', label: 'OK', color: 'var(--success)' },
|
||||||
|
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
|
||||||
|
{ value: 'failed', label: 'Error', color: 'var(--error)' },
|
||||||
|
{ value: 'running', label: 'Running', color: 'var(--running)' },
|
||||||
|
]
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
environment,
|
environment,
|
||||||
shift,
|
|
||||||
user,
|
user,
|
||||||
onSearchClick,
|
|
||||||
className,
|
className,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
|
const globalFilters = useGlobalFilters()
|
||||||
|
const commandPalette = useCommandPalette()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
||||||
|
|
||||||
{/* Center: Search trigger */}
|
{/* Search trigger */}
|
||||||
<button
|
<button
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
onClick={onSearchClick}
|
onClick={() => commandPalette.setOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Open search"
|
aria-label="Open search"
|
||||||
>
|
>
|
||||||
@@ -42,18 +55,46 @@ export function TopBar({
|
|||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.searchPlaceholder}>Search by Order ID, route, error...</span>
|
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||||
<span className={styles.kbd}>Ctrl+K</span>
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Right: env badge, shift, user */}
|
{/* Status filter group */}
|
||||||
|
<ButtonGroup
|
||||||
|
items={STATUS_ITEMS}
|
||||||
|
value={globalFilters.statusFilters}
|
||||||
|
onChange={(selected) => {
|
||||||
|
// Sync with global filter by toggling the diff
|
||||||
|
const current = globalFilters.statusFilters
|
||||||
|
for (const v of selected) {
|
||||||
|
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
||||||
|
}
|
||||||
|
for (const v of current) {
|
||||||
|
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Time range pills */}
|
||||||
|
<TimeRangeDropdown
|
||||||
|
value={globalFilters.timeRange}
|
||||||
|
onChange={globalFilters.setTimeRange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right: theme toggle, env badge, user */}
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
|
<button
|
||||||
|
className={styles.themeToggle}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
|
>
|
||||||
|
{theme === 'light' ? '\u263E' : '\u2600'}
|
||||||
|
</button>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<span className={styles.env}>{environment}</span>
|
||||||
)}
|
)}
|
||||||
{shift && (
|
|
||||||
<span className={styles.shift}>Shift: {shift}</span>
|
|
||||||
)}
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className={styles.user}>
|
<div className={styles.user}>
|
||||||
<span className={styles.userName}>{user.name}</span>
|
<span className={styles.userName}>{user.name}</span>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashed {
|
.dashed {
|
||||||
background: transparent !important;
|
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.group {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--amber);
|
||||||
|
outline-offset: -2px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state — default (no color override) */
|
||||||
|
.active {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dot indicator */
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotMuted {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
60
src/design-system/primitives/ButtonGroup/ButtonGroup.tsx
Normal file
60
src/design-system/primitives/ButtonGroup/ButtonGroup.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
import styles from './ButtonGroup.module.css'
|
||||||
|
|
||||||
|
export interface ButtonGroupItem {
|
||||||
|
value: string
|
||||||
|
label: ReactNode
|
||||||
|
/** Optional color for dot indicator and active tint */
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonGroupProps {
|
||||||
|
items: ButtonGroupItem[]
|
||||||
|
/** Currently selected values (multi-select) */
|
||||||
|
value: Set<string>
|
||||||
|
onChange: (value: Set<string>) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonGroup({ items, value, onChange, className }: ButtonGroupProps) {
|
||||||
|
function handleClick(itemValue: string) {
|
||||||
|
const next = new Set(value)
|
||||||
|
if (next.has(itemValue)) {
|
||||||
|
next.delete(itemValue)
|
||||||
|
} else {
|
||||||
|
next.add(itemValue)
|
||||||
|
}
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.group} ${className ?? ''}`} role="group">
|
||||||
|
{items.map((item) => {
|
||||||
|
const active = value.has(item.value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.btn} ${active ? styles.active : ''}`}
|
||||||
|
style={active && item.color ? {
|
||||||
|
borderColor: item.color,
|
||||||
|
color: item.color,
|
||||||
|
background: `color-mix(in srgb, ${item.color} 10%, transparent)`,
|
||||||
|
} : undefined}
|
||||||
|
onClick={() => handleClick(item.value)}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
{item.color && (
|
||||||
|
<span
|
||||||
|
className={`${styles.dot} ${active ? '' : styles.dotMuted}`}
|
||||||
|
style={{ background: item.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,15 +4,17 @@ import userEvent from '@testing-library/user-event'
|
|||||||
import { DateRangePicker } from './DateRangePicker'
|
import { DateRangePicker } from './DateRangePicker'
|
||||||
|
|
||||||
describe('DateRangePicker', () => {
|
describe('DateRangePicker', () => {
|
||||||
it('renders two datetime inputs', () => {
|
it('renders two datetime picker triggers', () => {
|
||||||
const { container } = render(
|
render(
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
value={{ start: new Date(), end: new Date() }}
|
value={{ start: new Date('2026-03-19T10:00'), end: new Date('2026-03-19T11:00') }}
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
const inputs = container.querySelectorAll('input[type="datetime-local"]')
|
// DateTimePicker renders button triggers with formatted date text
|
||||||
expect(inputs.length).toBe(2)
|
const buttons = screen.getAllByRole('button')
|
||||||
|
// At least 2 buttons are the from/to date picker triggers (plus preset pills)
|
||||||
|
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders preset buttons', () => {
|
it('renders preset buttons', () => {
|
||||||
|
|||||||
@@ -2,53 +2,7 @@ import { useState } from 'react'
|
|||||||
import styles from './DateRangePicker.module.css'
|
import styles from './DateRangePicker.module.css'
|
||||||
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
||||||
import { FilterPill } from '../FilterPill/FilterPill'
|
import { FilterPill } from '../FilterPill/FilterPill'
|
||||||
|
import { DEFAULT_PRESETS, computePresetRange, type DateRange, type Preset } from '../../utils/timePresets'
|
||||||
interface DateRange {
|
|
||||||
start: Date
|
|
||||||
end: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Preset {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PRESETS: Preset[] = [
|
|
||||||
{ label: 'Last 1h', value: 'last-1h' },
|
|
||||||
{ label: 'Last 6h', value: 'last-6h' },
|
|
||||||
{ label: 'Today', value: 'today' },
|
|
||||||
{ label: 'This shift', value: 'shift' },
|
|
||||||
{ label: 'Last 24h', value: 'last-24h' },
|
|
||||||
{ label: 'Last 7d', value: 'last-7d' },
|
|
||||||
{ label: 'Custom', value: 'custom' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function computePresetRange(preset: string): DateRange {
|
|
||||||
const now = new Date()
|
|
||||||
const end = now
|
|
||||||
|
|
||||||
switch (preset) {
|
|
||||||
case 'last-1h':
|
|
||||||
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
|
|
||||||
case 'last-6h':
|
|
||||||
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
|
|
||||||
case 'today': {
|
|
||||||
const start = new Date(now)
|
|
||||||
start.setHours(0, 0, 0, 0)
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
case 'shift': {
|
|
||||||
// "This shift" = last 8 hours
|
|
||||||
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
|
|
||||||
}
|
|
||||||
case 'last-24h':
|
|
||||||
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
|
|
||||||
case 'last-7d':
|
|
||||||
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
|
|
||||||
default:
|
|
||||||
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
value: DateRange
|
value: DateRange
|
||||||
|
|||||||
@@ -12,26 +12,217 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.trigger {
|
||||||
width: 100%;
|
padding: 0 4px;
|
||||||
padding: 6px 10px;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:focus-visible {
|
||||||
|
outline: 1px solid var(--amber);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel */
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 600;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 12px;
|
||||||
|
width: 260px;
|
||||||
|
animation: panelIn 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panelIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month navigation */
|
||||||
|
.monthNav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monthLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar grid */
|
||||||
|
.calendar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayHeader {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayEmpty {
|
||||||
|
/* placeholder for offset days */
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayToday {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daySelected {
|
||||||
|
background: var(--amber);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daySelected:hover {
|
||||||
|
background: var(--amber-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time selector */
|
||||||
|
.timeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeInput {
|
||||||
|
width: 32px;
|
||||||
|
padding: 4px 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.timeInput:focus {
|
||||||
border-color: var(--amber);
|
border-color: var(--amber);
|
||||||
box-shadow: 0 0 0 3px var(--amber-bg);
|
box-shadow: 0 0 0 2px var(--amber-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::-webkit-calendar-picker-indicator {
|
.timeSep {
|
||||||
opacity: 0.5;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayBtn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayBtn:hover {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doneBtn {
|
||||||
|
padding: 4px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--amber);
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doneBtn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doneBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,204 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import styles from './DateTimePicker.module.css'
|
import styles from './DateTimePicker.module.css'
|
||||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
|
||||||
|
|
||||||
interface DateTimePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {
|
interface DateTimePickerProps {
|
||||||
value?: Date
|
value?: Date
|
||||||
onChange?: (date: Date | null) => void
|
onChange?: (date: Date | null) => void
|
||||||
label?: string
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocalDateTimeString(date: Date): string {
|
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
return (
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
date.getFullYear() +
|
return new Date(year, month + 1, 0).getDate()
|
||||||
'-' +
|
|
||||||
pad(date.getMonth() + 1) +
|
|
||||||
'-' +
|
|
||||||
pad(date.getDate()) +
|
|
||||||
'T' +
|
|
||||||
pad(date.getHours()) +
|
|
||||||
':' +
|
|
||||||
pad(date.getMinutes())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DateTimePicker = forwardRef<HTMLInputElement, DateTimePickerProps>(
|
function getFirstDayOfWeek(year: number, month: number): number {
|
||||||
({ value, onChange, label, className, ...rest }, ref) => {
|
const day = new Date(year, month, 1).getDay()
|
||||||
const inputValue = value ? toLocalDateTimeString(value) : ''
|
return day === 0 ? 6 : day - 1 // Monday = 0
|
||||||
|
}
|
||||||
|
|
||||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function formatDisplay(d: Date | undefined): string {
|
||||||
if (!onChange) return
|
if (!d) return '—'
|
||||||
const v = e.target.value
|
const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
onChange(v ? new Date(v) : null)
|
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
return `${date}\u2009${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateTimePicker({ value, onChange, label, placeholder, className }: DateTimePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear())
|
||||||
|
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth())
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(value ?? null)
|
||||||
|
const [hour, setHour] = useState(value ? pad(value.getHours()) : pad(new Date().getHours()))
|
||||||
|
const [minute, setMinute] = useState(value ? pad(value.getMinutes()) : pad(new Date().getMinutes()))
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
|
||||||
|
// Sync when value changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
setSelectedDate(value)
|
||||||
|
setHour(pad(value.getHours()))
|
||||||
|
setMinute(pad(value.getMinutes()))
|
||||||
|
setViewYear(value.getFullYear())
|
||||||
|
setViewMonth(value.getMonth())
|
||||||
}
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const reposition = useCallback(() => {
|
||||||
|
if (!triggerRef.current) return
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
|
setPos({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
left: rect.left,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const id = requestAnimationFrame(reposition)
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}
|
||||||
|
}, [open, reposition])
|
||||||
|
|
||||||
|
// Close on Escape only — panel closes via Apply/Now buttons
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function handleDone() {
|
||||||
|
if (selectedDate) {
|
||||||
|
const d = new Date(selectedDate)
|
||||||
|
d.setHours(parseInt(hour, 10) || 0, parseInt(minute, 10) || 0, 0, 0)
|
||||||
|
onChange?.(d)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(day: number) {
|
||||||
|
const d = new Date(viewYear, viewMonth, day)
|
||||||
|
setSelectedDate(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNow() {
|
||||||
|
const now = new Date()
|
||||||
|
onChange?.(now)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1) }
|
||||||
|
else setViewMonth((m) => m - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1) }
|
||||||
|
else setViewMonth((m) => m + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
|
||||||
|
const firstDay = getFirstDayOfWeek(viewYear, viewMonth)
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
{label && <label className={styles.label}>{label}</label>}
|
{label && <span className={styles.label}>{label}</span>}
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={styles.trigger}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
{value ? formatDisplay(value) : (placeholder ?? '—')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className={styles.panel}
|
||||||
|
style={{ top: pos.top, left: pos.left }}
|
||||||
|
>
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className={styles.monthNav}>
|
||||||
|
<button type="button" className={styles.navBtn} onClick={prevMonth} aria-label="Previous month">◀</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
|
<input
|
||||||
ref={ref}
|
type="text"
|
||||||
type="datetime-local"
|
className={styles.timeInput}
|
||||||
className={styles.input}
|
value={hour}
|
||||||
value={inputValue}
|
onChange={(e) => setHour(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||||
onChange={handleChange}
|
maxLength={2}
|
||||||
{...rest}
|
aria-label="Hour"
|
||||||
|
/>
|
||||||
|
<span className={styles.timeSep}>:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.timeInput}
|
||||||
|
value={minute}
|
||||||
|
onChange={(e) => setMinute(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||||
|
maxLength={2}
|
||||||
|
aria-label="Minute"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.todayBtn} onClick={handleNow}>Now</button>
|
||||||
|
<button type="button" className={styles.doneBtn} onClick={handleDone} disabled={!selectedDate}>Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
|
||||||
DateTimePicker.displayName = 'DateTimePicker'
|
DateTimePicker.displayName = 'DateTimePicker'
|
||||||
|
|||||||
@@ -35,6 +35,14 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dotMuted {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeColored {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { forwardRef } from 'react'
|
||||||
import styles from './FilterPill.module.css'
|
import styles from './FilterPill.module.css'
|
||||||
|
|
||||||
interface FilterPillProps {
|
interface FilterPillProps {
|
||||||
@@ -6,31 +7,39 @@ interface FilterPillProps {
|
|||||||
active?: boolean
|
active?: boolean
|
||||||
dot?: boolean
|
dot?: boolean
|
||||||
dotColor?: string
|
dotColor?: string
|
||||||
|
activeColor?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterPill({
|
export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
|
||||||
|
({
|
||||||
label,
|
label,
|
||||||
count,
|
count,
|
||||||
active = false,
|
active = false,
|
||||||
dot = false,
|
dot = false,
|
||||||
dotColor,
|
dotColor,
|
||||||
|
activeColor,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}: FilterPillProps) {
|
}, ref) => {
|
||||||
const classes = [
|
const classes = [
|
||||||
styles.pill,
|
styles.pill,
|
||||||
active ? styles.active : '',
|
active ? styles.active : '',
|
||||||
|
active && activeColor ? styles.activeColored : '',
|
||||||
className ?? '',
|
className ?? '',
|
||||||
].filter(Boolean).join(' ')
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
const activeStyle = active && activeColor
|
||||||
|
? { borderColor: activeColor, backgroundColor: `color-mix(in srgb, ${activeColor} 12%, transparent)`, color: activeColor } as React.CSSProperties
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={classes} onClick={onClick} type="button">
|
<button ref={ref} className={classes} style={activeStyle} onClick={onClick} type="button" data-active={active || undefined}>
|
||||||
{dot && (
|
{dot && (
|
||||||
<span
|
<span
|
||||||
className={styles.dot}
|
className={`${styles.dot} ${!active ? styles.dotMuted : ''}`}
|
||||||
style={dotColor ? { background: dotColor } : undefined}
|
style={dotColor ? { background: active ? dotColor : undefined } : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className={styles.label}>{label}</span>
|
<span className={styles.label}>{label}</span>
|
||||||
@@ -39,4 +48,7 @@ export function FilterPill({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
FilterPill.displayName = 'FilterPill'
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display:hover .editBtn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBtn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, color 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBtn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled .editBtn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px 8px;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--amber-bg);
|
||||||
|
}
|
||||||
76
src/design-system/primitives/InlineEdit/InlineEdit.test.tsx
Normal file
76
src/design-system/primitives/InlineEdit/InlineEdit.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { InlineEdit } from './InlineEdit'
|
||||||
|
|
||||||
|
describe('InlineEdit', () => {
|
||||||
|
it('renders value in display mode', () => {
|
||||||
|
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows placeholder when value is empty', () => {
|
||||||
|
render(<InlineEdit value="" onSave={vi.fn()} placeholder="Enter name" />)
|
||||||
|
expect(screen.getByText('Enter name')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enters edit mode on click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||||||
|
await user.click(screen.getByText('Alice'))
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('Alice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves on Enter', async () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||||||
|
await user.click(screen.getByText('Alice'))
|
||||||
|
await user.clear(screen.getByRole('textbox'))
|
||||||
|
await user.type(screen.getByRole('textbox'), 'Bob')
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
expect(onSave).toHaveBeenCalledWith('Bob')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels on Escape', async () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||||||
|
await user.click(screen.getByText('Alice'))
|
||||||
|
await user.clear(screen.getByRole('textbox'))
|
||||||
|
await user.type(screen.getByRole('textbox'), 'Bob')
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels on blur', async () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||||||
|
await user.click(screen.getByText('Alice'))
|
||||||
|
await user.clear(screen.getByRole('textbox'))
|
||||||
|
await user.type(screen.getByRole('textbox'), 'Bob')
|
||||||
|
await user.tab()
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not enter edit mode when disabled', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<InlineEdit value="Alice" onSave={vi.fn()} disabled />)
|
||||||
|
await user.click(screen.getByText('Alice'))
|
||||||
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows edit icon button', () => {
|
||||||
|
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enters edit mode when edit button is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('Alice')
|
||||||
|
})
|
||||||
|
})
|
||||||
78
src/design-system/primitives/InlineEdit/InlineEdit.tsx
Normal file
78
src/design-system/primitives/InlineEdit/InlineEdit.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import styles from './InlineEdit.module.css'
|
||||||
|
|
||||||
|
export interface InlineEditProps {
|
||||||
|
value: string
|
||||||
|
onSave: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineEdit({ value, onSave, placeholder, disabled, className }: InlineEditProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(value)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
}
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (disabled) return
|
||||||
|
setDraft(value)
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
setEditing(false)
|
||||||
|
onSave(draft)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={`${styles.input} ${className ?? ''}`}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = !value
|
||||||
|
return (
|
||||||
|
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
||||||
|
<span
|
||||||
|
className={isEmpty ? styles.placeholder : styles.value}
|
||||||
|
onClick={startEdit}
|
||||||
|
>
|
||||||
|
{isEmpty ? placeholder : value}
|
||||||
|
</span>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
className={styles.editBtn}
|
||||||
|
onClick={startEdit}
|
||||||
|
aria-label="Edit"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import styles from './StatCard.module.css'
|
import styles from './StatCard.module.css'
|
||||||
import { Sparkline } from '../Sparkline/Sparkline'
|
import { Sparkline } from '../Sparkline/Sparkline'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: ReactNode
|
||||||
detail?: string
|
detail?: ReactNode
|
||||||
trend?: 'up' | 'down' | 'neutral'
|
trend?: 'up' | 'down' | 'neutral'
|
||||||
trendValue?: string
|
trendValue?: string
|
||||||
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running'
|
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running'
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.rangeRow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeSep {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import styles from './TimeRangeDropdown.module.css'
|
||||||
|
import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs'
|
||||||
|
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
||||||
|
import { computePresetRange } from '../../utils/timePresets'
|
||||||
|
import type { TimeRange } from '../../providers/GlobalFilterProvider'
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ value: 'last-1h', label: '1h' },
|
||||||
|
{ value: 'last-3h', label: '3h' },
|
||||||
|
{ value: 'last-6h', label: '6h' },
|
||||||
|
{ value: 'today', label: 'Today' },
|
||||||
|
{ value: 'last-24h', label: '24h' },
|
||||||
|
{ value: 'last-7d', label: '7d' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CUSTOM_VALUE = '__custom__'
|
||||||
|
|
||||||
|
interface TimeRangeDropdownProps {
|
||||||
|
value: TimeRange
|
||||||
|
onChange: (range: TimeRange) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
|
||||||
|
const [customFrom, setCustomFrom] = useState<Date>(value.start)
|
||||||
|
const [customTo, setCustomTo] = useState<Date>(value.end)
|
||||||
|
const [toIsSet, setToIsSet] = useState(false)
|
||||||
|
|
||||||
|
const isCustom = value.preset === null || value.preset === 'custom'
|
||||||
|
const activeValue = isCustom ? CUSTOM_VALUE : (value.preset ?? 'last-1h')
|
||||||
|
|
||||||
|
// Sync local state when value changes from presets
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomFrom(value.start)
|
||||||
|
setCustomTo(value.end)
|
||||||
|
}, [value.start, value.end])
|
||||||
|
|
||||||
|
function handleTabChange(tabValue: string) {
|
||||||
|
if (tabValue === CUSTOM_VALUE) return
|
||||||
|
setToIsSet(false)
|
||||||
|
const range = computePresetRange(tabValue)
|
||||||
|
onChange({ ...range, preset: tabValue })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFromChange(d: Date | null) {
|
||||||
|
if (!d) return
|
||||||
|
setCustomFrom(d)
|
||||||
|
// Only set preset to null; keep to-date as "now" if not explicitly set
|
||||||
|
if (toIsSet) {
|
||||||
|
onChange({ start: d, end: customTo, preset: null })
|
||||||
|
} else {
|
||||||
|
onChange({ start: d, end: new Date(), preset: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToChange(d: Date | null) {
|
||||||
|
if (!d) return
|
||||||
|
setCustomTo(d)
|
||||||
|
setToIsSet(true)
|
||||||
|
onChange({ start: customFrom, end: d, preset: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show "now" when to-date is not explicitly set
|
||||||
|
const showNow = !isCustom || !toIsSet
|
||||||
|
|
||||||
|
const rangeContent = (
|
||||||
|
<div className={styles.rangeRow}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={isCustom ? customFrom : value.start}
|
||||||
|
onChange={handleFromChange}
|
||||||
|
/>
|
||||||
|
<span className={styles.rangeSep}>–</span>
|
||||||
|
{showNow ? (
|
||||||
|
<DateTimePicker
|
||||||
|
value={undefined}
|
||||||
|
onChange={handleToChange}
|
||||||
|
placeholder="now"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DateTimePicker
|
||||||
|
value={customTo}
|
||||||
|
onChange={handleToChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<SegmentedTabs
|
||||||
|
tabs={PRESETS}
|
||||||
|
active={activeValue}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
trailing={rangeContent}
|
||||||
|
trailingValue={CUSTOM_VALUE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ export { Alert } from './Alert/Alert'
|
|||||||
export { Avatar } from './Avatar/Avatar'
|
export { Avatar } from './Avatar/Avatar'
|
||||||
export { Badge } from './Badge/Badge'
|
export { Badge } from './Badge/Badge'
|
||||||
export { Button } from './Button/Button'
|
export { Button } from './Button/Button'
|
||||||
|
export { ButtonGroup } from './ButtonGroup/ButtonGroup'
|
||||||
|
export type { ButtonGroupItem } from './ButtonGroup/ButtonGroup'
|
||||||
export { Card } from './Card/Card'
|
export { Card } from './Card/Card'
|
||||||
export { Checkbox } from './Checkbox/Checkbox'
|
export { Checkbox } from './Checkbox/Checkbox'
|
||||||
export { CodeBlock } from './CodeBlock/CodeBlock'
|
export { CodeBlock } from './CodeBlock/CodeBlock'
|
||||||
@@ -12,6 +14,8 @@ export { EmptyState } from './EmptyState/EmptyState'
|
|||||||
export { FilterPill } from './FilterPill/FilterPill'
|
export { FilterPill } from './FilterPill/FilterPill'
|
||||||
export { FormField } from './FormField/FormField'
|
export { FormField } from './FormField/FormField'
|
||||||
export { InfoCallout } from './InfoCallout/InfoCallout'
|
export { InfoCallout } from './InfoCallout/InfoCallout'
|
||||||
|
export { InlineEdit } from './InlineEdit/InlineEdit'
|
||||||
|
export type { InlineEditProps } from './InlineEdit/InlineEdit'
|
||||||
export { Input } from './Input/Input'
|
export { Input } from './Input/Input'
|
||||||
export { KeyboardHint } from './KeyboardHint/KeyboardHint'
|
export { KeyboardHint } from './KeyboardHint/KeyboardHint'
|
||||||
export { Label } from './Label/Label'
|
export { Label } from './Label/Label'
|
||||||
@@ -28,5 +32,6 @@ export { StatCard } from './StatCard/StatCard'
|
|||||||
export { StatusDot } from './StatusDot/StatusDot'
|
export { StatusDot } from './StatusDot/StatusDot'
|
||||||
export { Tag } from './Tag/Tag'
|
export { Tag } from './Tag/Tag'
|
||||||
export { Textarea } from './Textarea/Textarea'
|
export { Textarea } from './Textarea/Textarea'
|
||||||
|
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
|
||||||
export { Toggle } from './Toggle/Toggle'
|
export { Toggle } from './Toggle/Toggle'
|
||||||
export { Tooltip } from './Tooltip/Tooltip'
|
export { Tooltip } from './Tooltip/Tooltip'
|
||||||
|
|||||||
28
src/design-system/providers/CommandPaletteProvider.tsx
Normal file
28
src/design-system/providers/CommandPaletteProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface CommandPaletteContextValue {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null)
|
||||||
|
|
||||||
|
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [open, setOpenState] = useState(false)
|
||||||
|
|
||||||
|
const setOpen = useCallback((value: boolean) => {
|
||||||
|
setOpenState(value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteContext.Provider value={{ open, setOpen }}>
|
||||||
|
{children}
|
||||||
|
</CommandPaletteContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandPalette(): CommandPaletteContextValue {
|
||||||
|
const ctx = useContext(CommandPaletteContext)
|
||||||
|
if (!ctx) throw new Error('useCommandPalette must be used within CommandPaletteProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
79
src/design-system/providers/GlobalFilterProvider.tsx
Normal file
79
src/design-system/providers/GlobalFilterProvider.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
import { computePresetRange } from '../utils/timePresets'
|
||||||
|
|
||||||
|
export interface TimeRange {
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
preset: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
||||||
|
|
||||||
|
interface GlobalFilterContextValue {
|
||||||
|
timeRange: TimeRange
|
||||||
|
setTimeRange: (range: TimeRange) => void
|
||||||
|
statusFilters: Set<ExchangeStatus>
|
||||||
|
toggleStatus: (status: ExchangeStatus) => void
|
||||||
|
clearStatusFilters: () => void
|
||||||
|
isInTimeRange: (timestamp: Date) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||||
|
|
||||||
|
const DEFAULT_PRESET = 'last-1h'
|
||||||
|
|
||||||
|
function getDefaultTimeRange(): TimeRange {
|
||||||
|
const { start, end } = computePresetRange(DEFAULT_PRESET)
|
||||||
|
return { start, end, preset: DEFAULT_PRESET }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
||||||
|
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
||||||
|
|
||||||
|
const setTimeRange = useCallback((range: TimeRange) => {
|
||||||
|
setTimeRangeState(range)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleStatus = useCallback((status: ExchangeStatus) => {
|
||||||
|
setStatusFilters((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(status)) {
|
||||||
|
next.delete(status)
|
||||||
|
} else {
|
||||||
|
next.add(status)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearStatusFilters = useCallback(() => {
|
||||||
|
setStatusFilters(new Set())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isInTimeRange = useCallback(
|
||||||
|
(timestamp: Date) => {
|
||||||
|
if (timeRange.preset) {
|
||||||
|
// Recompute from now so the window stays fresh
|
||||||
|
const { start } = computePresetRange(timeRange.preset)
|
||||||
|
return timestamp >= start
|
||||||
|
}
|
||||||
|
return timestamp >= timeRange.start && timestamp <= timeRange.end
|
||||||
|
},
|
||||||
|
[timeRange],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalFilterContext.Provider
|
||||||
|
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</GlobalFilterContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalFilters(): GlobalFilterContextValue {
|
||||||
|
const ctx = useContext(GlobalFilterContext)
|
||||||
|
if (!ctx) throw new Error('useGlobalFilters must be used within GlobalFilterProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
--sidebar-bg: #2C2520;
|
--sidebar-bg: #2C2520;
|
||||||
--sidebar-hover: #3A322C;
|
--sidebar-hover: #3A322C;
|
||||||
--sidebar-active: #4A3F38;
|
--sidebar-active: #4A3F38;
|
||||||
--sidebar-text: #BFB5A8;
|
--sidebar-text: #D8D0C6;
|
||||||
--sidebar-muted: #7A6F63;
|
--sidebar-muted: #9C9184;
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--text-primary: #1A1612;
|
--text-primary: #1A1612;
|
||||||
@@ -58,6 +58,10 @@
|
|||||||
--shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10);
|
--shadow-lg: 0 4px 16px rgba(44, 37, 32, 0.10);
|
||||||
--shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04);
|
--shadow-card: 0 1px 3px rgba(44, 37, 32, 0.04), 0 0 0 1px rgba(44, 37, 32, 0.04);
|
||||||
|
|
||||||
|
/* Accent: purple (for choice/router elements) */
|
||||||
|
--purple: #7C3AED;
|
||||||
|
--purple-bg: #F3EEFA;
|
||||||
|
|
||||||
/* Chart palette */
|
/* Chart palette */
|
||||||
--chart-1: #C6820E;
|
--chart-1: #C6820E;
|
||||||
--chart-2: #3D7C47;
|
--chart-2: #3D7C47;
|
||||||
@@ -80,7 +84,7 @@
|
|||||||
--sidebar-bg: #141210;
|
--sidebar-bg: #141210;
|
||||||
--sidebar-hover: #1E1B17;
|
--sidebar-hover: #1E1B17;
|
||||||
--sidebar-active: #28241E;
|
--sidebar-active: #28241E;
|
||||||
--sidebar-text: #A89E92;
|
--sidebar-text: #CCC4B8;
|
||||||
--sidebar-muted: #6A6058;
|
--sidebar-muted: #6A6058;
|
||||||
|
|
||||||
--text-primary: #E8E0D6;
|
--text-primary: #E8E0D6;
|
||||||
@@ -109,6 +113,9 @@
|
|||||||
--running-bg: #1A2628;
|
--running-bg: #1A2628;
|
||||||
--running-border: #243A3E;
|
--running-border: #243A3E;
|
||||||
|
|
||||||
|
--purple: #A78BFA;
|
||||||
|
--purple-bg: rgba(124, 58, 237, 0.15);
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
|
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
|||||||
14
src/design-system/types/css-modules.d.ts
vendored
Normal file
14
src/design-system/types/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: { readonly [key: string]: string }
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.css' {
|
||||||
|
const css: string
|
||||||
|
export default css
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const url: string
|
||||||
|
export default url
|
||||||
|
}
|
||||||
53
src/design-system/utils/timePresets.ts
Normal file
53
src/design-system/utils/timePresets.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export interface DateRange {
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Preset {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PRESETS: Preset[] = [
|
||||||
|
{ label: 'Last 1h', value: 'last-1h' },
|
||||||
|
{ label: 'Last 6h', value: 'last-6h' },
|
||||||
|
{ label: 'Today', value: 'today' },
|
||||||
|
{ label: 'Last 24h', value: 'last-24h' },
|
||||||
|
{ label: 'Last 7d', value: 'last-7d' },
|
||||||
|
{ label: 'Custom', value: 'custom' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PRESET_SHORT_LABELS: Record<string, string> = {
|
||||||
|
'last-1h': '1h',
|
||||||
|
'last-3h': '3h',
|
||||||
|
'last-6h': '6h',
|
||||||
|
'today': 'Today',
|
||||||
|
'last-24h': '24h',
|
||||||
|
'last-7d': '7d',
|
||||||
|
'custom': 'Custom',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePresetRange(preset: string): DateRange {
|
||||||
|
const now = new Date()
|
||||||
|
const end = now
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case 'last-1h':
|
||||||
|
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
|
||||||
|
case 'last-3h':
|
||||||
|
return { start: new Date(now.getTime() - 3 * 60 * 60 * 1000), end }
|
||||||
|
case 'last-6h':
|
||||||
|
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
|
||||||
|
case 'today': {
|
||||||
|
const start = new Date(now)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
case 'last-24h':
|
||||||
|
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
|
||||||
|
case 'last-7d':
|
||||||
|
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
|
||||||
|
default:
|
||||||
|
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { ThemeProvider } from './design-system/providers/ThemeProvider'
|
import { ThemeProvider } from './design-system/providers/ThemeProvider'
|
||||||
|
import { GlobalFilterProvider } from './design-system/providers/GlobalFilterProvider'
|
||||||
|
import { CommandPaletteProvider } from './design-system/providers/CommandPaletteProvider'
|
||||||
|
import { ToastProvider } from './design-system/composites/Toast/Toast'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
@@ -9,7 +12,13 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<GlobalFilterProvider>
|
||||||
|
<CommandPaletteProvider>
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</CommandPaletteProvider>
|
||||||
|
</GlobalFilterProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface Exchange {
|
|||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
errorClass?: string
|
errorClass?: string
|
||||||
processors: ProcessorData[]
|
processors: ProcessorData[]
|
||||||
|
correlationGroup?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const exchanges: Exchange[] = [
|
export const exchanges: Exchange[] = [
|
||||||
@@ -34,6 +35,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T09:12:04'),
|
timestamp: new Date('2026-03-18T09:12:04'),
|
||||||
correlationId: 'cmr-f4a1c82b-9d3e',
|
correlationId: 'cmr-f4a1c82b-9d3e',
|
||||||
agent: 'prod-1',
|
agent: 'prod-1',
|
||||||
|
correlationGroup: 'order-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
|
||||||
@@ -53,6 +55,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T09:11:22'),
|
timestamp: new Date('2026-03-18T09:11:22'),
|
||||||
correlationId: 'cmr-7b2d9f14-c5a8',
|
correlationId: 'cmr-7b2d9f14-c5a8',
|
||||||
agent: 'prod-2',
|
agent: 'prod-2',
|
||||||
|
correlationGroup: 'payment-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 5 },
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 5 },
|
||||||
@@ -72,6 +75,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T09:13:44'),
|
timestamp: new Date('2026-03-18T09:13:44'),
|
||||||
correlationId: 'cmr-3c8e1a7f-d2b6',
|
correlationId: 'cmr-3c8e1a7f-d2b6',
|
||||||
agent: 'prod-1',
|
agent: 'prod-1',
|
||||||
|
correlationGroup: 'order-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||||
{ name: 'enrich(inventory-api)', type: 'enrich', durationMs: 29990, status: 'slow', startMs: 5 },
|
{ name: 'enrich(inventory-api)', type: 'enrich', durationMs: 29990, status: 'slow', startMs: 5 },
|
||||||
@@ -88,6 +92,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T09:09:47'),
|
timestamp: new Date('2026-03-18T09:09:47'),
|
||||||
correlationId: 'cmr-a9f3b2c1-e4d7',
|
correlationId: 'cmr-a9f3b2c1-e4d7',
|
||||||
agent: 'prod-3',
|
agent: 'prod-3',
|
||||||
|
correlationGroup: 'shipment-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 6, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 6, status: 'ok', startMs: 0 },
|
||||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 6 },
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 6 },
|
||||||
@@ -106,6 +111,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T09:06:11'),
|
timestamp: new Date('2026-03-18T09:06:11'),
|
||||||
correlationId: 'cmr-9a4f2b71-e8c3',
|
correlationId: 'cmr-9a4f2b71-e8c3',
|
||||||
agent: 'prod-2',
|
agent: 'prod-2',
|
||||||
|
correlationGroup: 'payment-flow-002',
|
||||||
errorMessage: 'org.apache.camel.CamelExecutionException: Payment gateway timeout after 5000ms — POST https://pay.provider.com/v2/charge returned HTTP 504. Retry exhausted (3/3).',
|
errorMessage: 'org.apache.camel.CamelExecutionException: Payment gateway timeout after 5000ms — POST https://pay.provider.com/v2/charge returned HTTP 504. Retry exhausted (3/3).',
|
||||||
errorClass: 'org.apache.camel.CamelExecutionException',
|
errorClass: 'org.apache.camel.CamelExecutionException',
|
||||||
processors: [
|
processors: [
|
||||||
@@ -145,6 +151,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T09:00:15'),
|
timestamp: new Date('2026-03-18T09:00:15'),
|
||||||
correlationId: 'cmr-2e5f8d9a-b4c1',
|
correlationId: 'cmr-2e5f8d9a-b4c1',
|
||||||
agent: 'prod-3',
|
agent: 'prod-3',
|
||||||
|
correlationGroup: 'order-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
||||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 5, status: 'ok', startMs: 3 },
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 5, status: 'ok', startMs: 3 },
|
||||||
@@ -164,6 +171,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T08:58:33'),
|
timestamp: new Date('2026-03-18T08:58:33'),
|
||||||
correlationId: 'cmr-d1a3e7f4-c2b8',
|
correlationId: 'cmr-d1a3e7f4-c2b8',
|
||||||
agent: 'prod-1',
|
agent: 'prod-1',
|
||||||
|
correlationGroup: 'payment-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||||
{ name: 'validate(payment-schema)', type: 'process', durationMs: 14, status: 'ok', startMs: 4 },
|
{ name: 'validate(payment-schema)', type: 'process', durationMs: 14, status: 'ok', startMs: 4 },
|
||||||
@@ -199,6 +207,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T08:50:41'),
|
timestamp: new Date('2026-03-18T08:50:41'),
|
||||||
correlationId: 'cmr-f3c7a1b9-d5e2',
|
correlationId: 'cmr-f3c7a1b9-d5e2',
|
||||||
agent: 'prod-1',
|
agent: 'prod-1',
|
||||||
|
correlationGroup: 'order-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
||||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 3 },
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 3 },
|
||||||
@@ -218,6 +227,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T08:46:19'),
|
timestamp: new Date('2026-03-18T08:46:19'),
|
||||||
correlationId: 'cmr-a2d8f5c3-b9e1',
|
correlationId: 'cmr-a2d8f5c3-b9e1',
|
||||||
agent: 'prod-2',
|
agent: 'prod-2',
|
||||||
|
correlationGroup: 'payment-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||||
{ name: 'validate(payment-schema)', type: 'process', durationMs: 12, status: 'ok', startMs: 5 },
|
{ name: 'validate(payment-schema)', type: 'process', durationMs: 12, status: 'ok', startMs: 5 },
|
||||||
@@ -254,6 +264,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T08:31:05'),
|
timestamp: new Date('2026-03-18T08:31:05'),
|
||||||
correlationId: 'cmr-7e9a2c5f-d1b4',
|
correlationId: 'cmr-7e9a2c5f-d1b4',
|
||||||
agent: 'prod-2',
|
agent: 'prod-2',
|
||||||
|
correlationGroup: 'payment-flow-002',
|
||||||
errorMessage: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP operation failed invoking https://pay.provider.com/v2/charge with statusCode: 422 — Unprocessable Entity: card declined (insufficient funds)',
|
errorMessage: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP operation failed invoking https://pay.provider.com/v2/charge with statusCode: 422 — Unprocessable Entity: card declined (insufficient funds)',
|
||||||
errorClass: 'org.apache.camel.component.http.HttpOperationFailedException',
|
errorClass: 'org.apache.camel.component.http.HttpOperationFailedException',
|
||||||
processors: [
|
processors: [
|
||||||
@@ -273,6 +284,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T08:22:44'),
|
timestamp: new Date('2026-03-18T08:22:44'),
|
||||||
correlationId: 'cmr-b5c8d2a7-f4e3',
|
correlationId: 'cmr-b5c8d2a7-f4e3',
|
||||||
agent: 'prod-3',
|
agent: 'prod-3',
|
||||||
|
correlationGroup: 'shipment-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 5 },
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 5 },
|
||||||
@@ -291,6 +303,7 @@ export const exchanges: Exchange[] = [
|
|||||||
timestamp: new Date('2026-03-18T08:15:19'),
|
timestamp: new Date('2026-03-18T08:15:19'),
|
||||||
correlationId: 'cmr-d9e3f7b1-a6c5',
|
correlationId: 'cmr-d9e3f7b1-a6c5',
|
||||||
agent: 'prod-4',
|
agent: 'prod-4',
|
||||||
|
correlationGroup: 'order-flow-001',
|
||||||
processors: [
|
processors: [
|
||||||
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||||
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 4 },
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 4 },
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface MetricSeries {
|
|||||||
data: TimeSeriesPoint[]
|
data: TimeSeriesPoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a realistic time series for the past shift (06:00 - now ~09:15)
|
// Generate a realistic time series for the past hours (06:00 - now ~09:15)
|
||||||
function generateTimeSeries(
|
function generateTimeSeries(
|
||||||
baseValue: number,
|
baseValue: number,
|
||||||
variance: number,
|
variance: number,
|
||||||
@@ -44,12 +44,12 @@ function generateTimeSeries(
|
|||||||
// KPI stat cards data
|
// KPI stat cards data
|
||||||
export const kpiMetrics: KpiMetric[] = [
|
export const kpiMetrics: KpiMetric[] = [
|
||||||
{
|
{
|
||||||
label: 'Exchanges (shift)',
|
label: 'Exchanges',
|
||||||
value: '3,241',
|
value: '3,241',
|
||||||
trend: 'up',
|
trend: 'up',
|
||||||
trendValue: '+12%',
|
trendValue: '+12%',
|
||||||
trendSentiment: 'good',
|
trendSentiment: 'good',
|
||||||
detail: '97.1% success since 06:00',
|
detail: '97.1% success rate',
|
||||||
accent: 'amber',
|
accent: 'amber',
|
||||||
sparkline: [28, 32, 29, 35, 38, 41, 37, 44, 42, 47, 45, 51, 48, 52],
|
sparkline: [28, 32, 29, 35, 38, 41, 37, 44, 42, 47, 45, 51, 48, 52],
|
||||||
},
|
},
|
||||||
@@ -64,12 +64,12 @@ export const kpiMetrics: KpiMetric[] = [
|
|||||||
sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1],
|
sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Errors (shift)',
|
label: 'Errors',
|
||||||
value: 38,
|
value: 38,
|
||||||
trend: 'up',
|
trend: 'up',
|
||||||
trendValue: '+5',
|
trendValue: '+5',
|
||||||
trendSentiment: 'bad',
|
trendSentiment: 'bad',
|
||||||
detail: '23 overnight · 15 since 06:00',
|
detail: '38 errors in selected period',
|
||||||
accent: 'error',
|
accent: 'error',
|
||||||
sparkline: [1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6, 8],
|
sparkline: [1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6, 8],
|
||||||
},
|
},
|
||||||
@@ -147,6 +147,7 @@ export const errorCountSeries: MetricSeries[] = [
|
|||||||
export interface RouteMetricRow {
|
export interface RouteMetricRow {
|
||||||
routeId: string
|
routeId: string
|
||||||
routeName: string
|
routeName: string
|
||||||
|
appId: string
|
||||||
exchangeCount: number
|
exchangeCount: number
|
||||||
successRate: number
|
successRate: number
|
||||||
avgDurationMs: number
|
avgDurationMs: number
|
||||||
@@ -159,6 +160,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
|||||||
{
|
{
|
||||||
routeId: 'order-intake',
|
routeId: 'order-intake',
|
||||||
routeName: 'order-intake',
|
routeName: 'order-intake',
|
||||||
|
appId: 'order-service',
|
||||||
exchangeCount: 892,
|
exchangeCount: 892,
|
||||||
successRate: 99.2,
|
successRate: 99.2,
|
||||||
avgDurationMs: 88,
|
avgDurationMs: 88,
|
||||||
@@ -169,6 +171,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
|||||||
{
|
{
|
||||||
routeId: 'order-enrichment',
|
routeId: 'order-enrichment',
|
||||||
routeName: 'order-enrichment',
|
routeName: 'order-enrichment',
|
||||||
|
appId: 'order-service',
|
||||||
exchangeCount: 541,
|
exchangeCount: 541,
|
||||||
successRate: 97.6,
|
successRate: 97.6,
|
||||||
avgDurationMs: 156,
|
avgDurationMs: 156,
|
||||||
@@ -179,6 +182,7 @@ export const routeMetrics: RouteMetricRow[] = [
|
|||||||
{
|
{
|
||||||
routeId: 'payment-process',
|
routeId: 'payment-process',
|
||||||
routeName: 'payment-process',
|
routeName: 'payment-process',
|
||||||
|
appId: 'payment-svc',
|
||||||
exchangeCount: 414,
|
exchangeCount: 414,
|
||||||
successRate: 96.1,
|
successRate: 96.1,
|
||||||
avgDurationMs: 234,
|
avgDurationMs: 234,
|
||||||
@@ -186,9 +190,21 @@ export const routeMetrics: RouteMetricRow[] = [
|
|||||||
errorCount: 16,
|
errorCount: 16,
|
||||||
sparkline: [210, 225, 232, 218, 241, 235, 228, 242, 238, 231, 244, 237, 233, 234],
|
sparkline: [210, 225, 232, 218, 241, 235, 228, 242, 238, 231, 244, 237, 233, 234],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
routeId: 'payment-validate',
|
||||||
|
routeName: 'payment-validate',
|
||||||
|
appId: 'payment-svc',
|
||||||
|
exchangeCount: 498,
|
||||||
|
successRate: 99.8,
|
||||||
|
avgDurationMs: 142,
|
||||||
|
p99DurationMs: 198,
|
||||||
|
errorCount: 1,
|
||||||
|
sparkline: [138, 141, 140, 143, 145, 142, 144, 141, 139, 143, 142, 140, 141, 142],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
routeId: 'shipment-dispatch',
|
routeId: 'shipment-dispatch',
|
||||||
routeName: 'shipment-dispatch',
|
routeName: 'shipment-dispatch',
|
||||||
|
appId: 'shipment-tracker',
|
||||||
exchangeCount: 387,
|
exchangeCount: 387,
|
||||||
successRate: 98.4,
|
successRate: 98.4,
|
||||||
avgDurationMs: 118,
|
avgDurationMs: 118,
|
||||||
@@ -196,4 +212,26 @@ export const routeMetrics: RouteMetricRow[] = [
|
|||||||
errorCount: 6,
|
errorCount: 6,
|
||||||
sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118],
|
sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
routeId: 'shipment-track',
|
||||||
|
routeName: 'shipment-track',
|
||||||
|
appId: 'shipment-tracker',
|
||||||
|
exchangeCount: 923,
|
||||||
|
successRate: 99.5,
|
||||||
|
avgDurationMs: 94,
|
||||||
|
p99DurationMs: 167,
|
||||||
|
errorCount: 5,
|
||||||
|
sparkline: [88, 91, 93, 95, 92, 94, 96, 93, 91, 95, 94, 92, 93, 94],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routeId: 'notification-dispatch',
|
||||||
|
routeName: 'notification-dispatch',
|
||||||
|
appId: 'notification-hub',
|
||||||
|
exchangeCount: 471,
|
||||||
|
successRate: 98.9,
|
||||||
|
avgDurationMs: 62,
|
||||||
|
p99DurationMs: 124,
|
||||||
|
errorCount: 5,
|
||||||
|
sparkline: [58, 60, 63, 61, 64, 62, 60, 63, 65, 62, 61, 63, 62, 62],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
100
src/mocks/searchData.tsx
Normal file
100
src/mocks/searchData.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { SearchResult } from '../design-system/composites/CommandPalette/types'
|
||||||
|
import { exchanges, type Exchange } from './exchanges'
|
||||||
|
import { routes } from './routes'
|
||||||
|
import { agents } from './agents'
|
||||||
|
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||||
|
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||||
|
return `${ms}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: Exchange['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'OK'
|
||||||
|
case 'failed': return 'ERR'
|
||||||
|
case 'running': return 'RUN'
|
||||||
|
case 'warning': return 'WARN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToVariant(status: Exchange['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success'
|
||||||
|
case 'failed': return 'error'
|
||||||
|
case 'running': return 'running'
|
||||||
|
case 'warning': return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(date: Date): string {
|
||||||
|
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthToColor(health: SidebarApp['health']): string {
|
||||||
|
switch (health) {
|
||||||
|
case 'live': return 'success'
|
||||||
|
case 'stale': return 'warning'
|
||||||
|
case 'dead': return 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSearchData(
|
||||||
|
exs: Exchange[] = exchanges,
|
||||||
|
rts: typeof routes = routes,
|
||||||
|
ags: typeof agents = agents,
|
||||||
|
apps: SidebarApp[] = SIDEBAR_APPS,
|
||||||
|
): SearchResult[] {
|
||||||
|
const results: SearchResult[] = []
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
const liveAgents = app.agents.filter((a) => a.status === 'live').length
|
||||||
|
results.push({
|
||||||
|
id: app.id,
|
||||||
|
category: 'application',
|
||||||
|
title: app.name,
|
||||||
|
badges: [{ label: app.health.toUpperCase(), color: healthToColor(app.health) }],
|
||||||
|
meta: `${app.routes.length} routes · ${app.agents.length} agents (${liveAgents} live) · ${app.exchangeCount.toLocaleString()} exchanges`,
|
||||||
|
path: `/apps/${app.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const exec of exs) {
|
||||||
|
results.push({
|
||||||
|
id: exec.id,
|
||||||
|
category: 'exchange',
|
||||||
|
title: `${exec.orderId} — ${exec.route}`,
|
||||||
|
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
|
||||||
|
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
|
||||||
|
timestamp: formatTimestamp(exec.timestamp),
|
||||||
|
path: `/exchanges/${exec.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeToApp = buildRouteToAppMap(apps)
|
||||||
|
for (const route of rts) {
|
||||||
|
const appIdForRoute = routeToApp.get(route.id)
|
||||||
|
results.push({
|
||||||
|
id: route.id,
|
||||||
|
category: 'route',
|
||||||
|
title: route.name,
|
||||||
|
badges: [{ label: route.group }],
|
||||||
|
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
|
||||||
|
path: appIdForRoute ? `/apps/${appIdForRoute}/${route.id}` : `/apps/${route.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of ags) {
|
||||||
|
results.push({
|
||||||
|
id: agent.id,
|
||||||
|
category: 'agent',
|
||||||
|
title: agent.name,
|
||||||
|
badges: [{ label: agent.status }],
|
||||||
|
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
|
||||||
|
path: `/agents/${agent.appId}/${agent.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -20,6 +20,17 @@ export interface SidebarApp {
|
|||||||
agents: SidebarAgent[]
|
agents: SidebarAgent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build a routeId → appId lookup from the sidebar tree */
|
||||||
|
export function buildRouteToAppMap(apps: SidebarApp[] = SIDEBAR_APPS): Map<string, string> {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const app of apps) {
|
||||||
|
for (const route of app.routes) {
|
||||||
|
map.set(route.id, app.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
export const SIDEBAR_APPS: SidebarApp[] = [
|
export const SIDEBAR_APPS: SidebarApp[] = [
|
||||||
{
|
{
|
||||||
id: 'order-service',
|
id: 'order-service',
|
||||||
|
|||||||
5
src/pages/Admin/Admin.module.css
Normal file
5
src/pages/Admin/Admin.module.css
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.adminContent {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px 40px;
|
||||||
|
}
|
||||||
@@ -1,22 +1,45 @@
|
|||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
|
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||||
|
import styles from './Admin.module.css'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const ADMIN_TABS = [
|
||||||
|
{ label: 'User Management', value: '/admin/rbac' },
|
||||||
|
{ label: 'Audit Log', value: '/admin/audit' },
|
||||||
|
{ label: 'OIDC', value: '/admin/oidc' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface AdminLayoutProps {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminLayout({ title, children }: AdminLayoutProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
export function Admin() {
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[{ label: 'Admin' }]}
|
breadcrumb={[
|
||||||
|
{ label: 'Admin', href: '/admin' },
|
||||||
|
{ label: title },
|
||||||
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<Tabs
|
||||||
title="Admin Panel"
|
tabs={ADMIN_TABS}
|
||||||
description="Admin panel coming soon."
|
active={location.pathname}
|
||||||
|
onChange={(path) => navigate(path)}
|
||||||
/>
|
/>
|
||||||
|
<div className={styles.adminContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/pages/Admin/AuditLog/AuditLog.module.css
Normal file
86
src/pages/Admin/AuditLog/AuditLog.module.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterInput {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSelect {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableSection {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableMeta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandedDetail {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailValue {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
153
src/pages/Admin/AuditLog/AuditLog.tsx
Normal file
153
src/pages/Admin/AuditLog/AuditLog.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { AdminLayout } from '../Admin'
|
||||||
|
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||||
|
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
|
||||||
|
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||||
|
import { Select } from '../../../design-system/primitives/Select/Select'
|
||||||
|
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||||
|
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
|
||||||
|
import { DataTable } from '../../../design-system/composites/DataTable/DataTable'
|
||||||
|
import type { Column } from '../../../design-system/composites/DataTable/types'
|
||||||
|
import type { DateRange } from '../../../design-system/utils/timePresets'
|
||||||
|
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
|
||||||
|
import styles from './AuditLog.module.css'
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: '', label: 'All categories' },
|
||||||
|
{ value: 'INFRA', label: 'INFRA' },
|
||||||
|
{ value: 'AUTH', label: 'AUTH' },
|
||||||
|
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
||||||
|
{ value: 'CONFIG', label: 'CONFIG' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('en-GB', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: Column<AuditEvent>[] = [
|
||||||
|
{
|
||||||
|
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
|
||||||
|
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'username', header: 'User', sortable: true,
|
||||||
|
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'category', header: 'Category', width: '110px', sortable: true,
|
||||||
|
render: (_, row) => <Badge label={row.category} color="auto" />,
|
||||||
|
},
|
||||||
|
{ key: 'action', header: 'Action' },
|
||||||
|
{
|
||||||
|
key: 'target', header: 'Target',
|
||||||
|
render: (_, row) => <span className={styles.target}>{row.target}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'result', header: 'Result', width: '90px', sortable: true,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const INITIAL_RANGE: DateRange = {
|
||||||
|
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
|
||||||
|
to: new Date(now).toISOString().slice(0, 16),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLog() {
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
|
||||||
|
const [userFilter, setUserFilter] = useState('')
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('')
|
||||||
|
const [searchFilter, setSearchFilter] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const from = new Date(dateRange.from).getTime()
|
||||||
|
const to = new Date(dateRange.to).getTime()
|
||||||
|
return AUDIT_EVENTS.filter((e) => {
|
||||||
|
const ts = new Date(e.timestamp).getTime()
|
||||||
|
if (ts < from || ts > to) return false
|
||||||
|
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
|
||||||
|
if (categoryFilter && e.category !== categoryFilter) return false
|
||||||
|
if (searchFilter) {
|
||||||
|
const q = searchFilter.toLowerCase()
|
||||||
|
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [dateRange, userFilter, categoryFilter, searchFilter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Audit Log">
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by user..."
|
||||||
|
value={userFilter}
|
||||||
|
onChange={(e) => setUserFilter(e.target.value)}
|
||||||
|
onClear={() => setUserFilter('')}
|
||||||
|
className={styles.filterInput}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={CATEGORIES}
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Search action or target..."
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(e) => setSearchFilter(e.target.value)}
|
||||||
|
onClear={() => setSearchFilter('')}
|
||||||
|
className={styles.filterInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableSection}>
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.tableTitle}>Audit Log</span>
|
||||||
|
<div className={styles.tableRight}>
|
||||||
|
<span className={styles.tableMeta}>
|
||||||
|
{filtered.length} events
|
||||||
|
</span>
|
||||||
|
<Badge label="LIVE" color="success" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={COLUMNS}
|
||||||
|
data={filtered}
|
||||||
|
sortable
|
||||||
|
flush
|
||||||
|
pageSize={10}
|
||||||
|
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
|
||||||
|
expandedContent={(row) => (
|
||||||
|
<div className={styles.expandedDetail}>
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
<div className={styles.detailField}>
|
||||||
|
<span className={styles.detailLabel}>IP Address</span>
|
||||||
|
<MonoText size="xs">{row.ipAddress}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailField}>
|
||||||
|
<span className={styles.detailLabel}>User Agent</span>
|
||||||
|
<span className={styles.detailValue}>{row.userAgent}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailField}>
|
||||||
|
<span className={styles.detailLabel}>Detail</span>
|
||||||
|
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
src/pages/Admin/AuditLog/auditMocks.ts
Normal file
194
src/pages/Admin/AuditLog/auditMocks.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
export interface AuditEvent {
|
||||||
|
id: string
|
||||||
|
timestamp: string
|
||||||
|
username: string
|
||||||
|
category: 'INFRA' | 'AUTH' | 'USER_MGMT' | 'CONFIG'
|
||||||
|
action: string
|
||||||
|
target: string
|
||||||
|
result: 'SUCCESS' | 'FAILURE'
|
||||||
|
detail: Record<string, unknown>
|
||||||
|
ipAddress: string
|
||||||
|
userAgent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const hour = 3600_000
|
||||||
|
const day = 24 * hour
|
||||||
|
|
||||||
|
export const AUDIT_EVENTS: AuditEvent[] = [
|
||||||
|
{
|
||||||
|
id: 'audit-1', timestamp: new Date(now - 0.5 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
|
||||||
|
target: 'users/alice', result: 'SUCCESS',
|
||||||
|
detail: { displayName: 'Alice Johnson', roles: ['VIEWER'] },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-2', timestamp: new Date(now - 1.2 * hour).toISOString(),
|
||||||
|
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
|
||||||
|
target: 'db/primary', result: 'SUCCESS',
|
||||||
|
detail: { oldSize: 10, newSize: 20, reason: 'auto-scale' },
|
||||||
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-3', timestamp: new Date(now - 2 * hour).toISOString(),
|
||||||
|
username: 'alice', category: 'AUTH', action: 'LOGIN',
|
||||||
|
target: 'sessions/abc123', result: 'SUCCESS',
|
||||||
|
detail: { method: 'OIDC', provider: 'keycloak' },
|
||||||
|
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-4', timestamp: new Date(now - 2.5 * hour).toISOString(),
|
||||||
|
username: 'unknown', category: 'AUTH', action: 'LOGIN',
|
||||||
|
target: 'sessions', result: 'FAILURE',
|
||||||
|
detail: { method: 'local', reason: 'invalid_credentials' },
|
||||||
|
ipAddress: '203.0.113.50', userAgent: 'curl/8.1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-5', timestamp: new Date(now - 3 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
|
||||||
|
target: 'thresholds/pool-connections', result: 'SUCCESS',
|
||||||
|
detail: { field: 'maxConnections', oldValue: 50, newValue: 100 },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-6', timestamp: new Date(now - 4 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
|
||||||
|
target: 'users/bob', result: 'SUCCESS',
|
||||||
|
detail: { role: 'EDITOR', method: 'direct' },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-7', timestamp: new Date(now - 5 * hour).toISOString(),
|
||||||
|
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
|
||||||
|
target: 'opensearch/exchanges', result: 'SUCCESS',
|
||||||
|
detail: { documents: 15420, duration: '12.3s' },
|
||||||
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-8', timestamp: new Date(now - 6 * hour).toISOString(),
|
||||||
|
username: 'bob', category: 'AUTH', action: 'LOGIN',
|
||||||
|
target: 'sessions/def456', result: 'SUCCESS',
|
||||||
|
detail: { method: 'local' },
|
||||||
|
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-9', timestamp: new Date(now - 8 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_GROUP',
|
||||||
|
target: 'groups/developers', result: 'SUCCESS',
|
||||||
|
detail: { parent: null },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-10', timestamp: new Date(now - 10 * hour).toISOString(),
|
||||||
|
username: 'system', category: 'INFRA', action: 'BACKUP',
|
||||||
|
target: 'db/primary', result: 'SUCCESS',
|
||||||
|
detail: { sizeBytes: 524288000, duration: '45s' },
|
||||||
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-11', timestamp: new Date(now - 12 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_OIDC',
|
||||||
|
target: 'config/oidc', result: 'SUCCESS',
|
||||||
|
detail: { field: 'autoSignup', oldValue: false, newValue: true },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-12', timestamp: new Date(now - 1 * day).toISOString(),
|
||||||
|
username: 'alice', category: 'AUTH', action: 'LOGOUT',
|
||||||
|
target: 'sessions/abc123', result: 'SUCCESS',
|
||||||
|
detail: { reason: 'user_initiated' },
|
||||||
|
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-13', timestamp: new Date(now - 1 * day - 2 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'USER_MGMT', action: 'DELETE_USER',
|
||||||
|
target: 'users/temp-user', result: 'SUCCESS',
|
||||||
|
detail: { reason: 'cleanup' },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-14', timestamp: new Date(now - 1 * day - 4 * hour).toISOString(),
|
||||||
|
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
|
||||||
|
target: 'db/primary', result: 'FAILURE',
|
||||||
|
detail: { oldSize: 20, newSize: 50, error: 'max_connections_exceeded' },
|
||||||
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-15', timestamp: new Date(now - 1 * day - 6 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'USER_MGMT', action: 'UPDATE_GROUP',
|
||||||
|
target: 'groups/admins', result: 'SUCCESS',
|
||||||
|
detail: { addedMembers: ['alice'], removedMembers: [] },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-16', timestamp: new Date(now - 2 * day).toISOString(),
|
||||||
|
username: 'bob', category: 'AUTH', action: 'PASSWORD_CHANGE',
|
||||||
|
target: 'users/bob', result: 'SUCCESS',
|
||||||
|
detail: { method: 'self_service' },
|
||||||
|
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-17', timestamp: new Date(now - 2 * day - 3 * hour).toISOString(),
|
||||||
|
username: 'system', category: 'INFRA', action: 'VACUUM',
|
||||||
|
target: 'db/primary/exchanges', result: 'SUCCESS',
|
||||||
|
detail: { reclaimedBytes: 1048576, duration: '3.2s' },
|
||||||
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-18', timestamp: new Date(now - 2 * day - 5 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
|
||||||
|
target: 'thresholds/latency-p99', result: 'SUCCESS',
|
||||||
|
detail: { field: 'warningMs', oldValue: 500, newValue: 300 },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-19', timestamp: new Date(now - 3 * day).toISOString(),
|
||||||
|
username: 'attacker', category: 'AUTH', action: 'LOGIN',
|
||||||
|
target: 'sessions', result: 'FAILURE',
|
||||||
|
detail: { method: 'local', reason: 'account_locked', attempts: 5 },
|
||||||
|
ipAddress: '198.51.100.23', userAgent: 'python-requests/2.31',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-20', timestamp: new Date(now - 3 * day - 2 * hour).toISOString(),
|
||||||
|
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
|
||||||
|
target: 'groups/developers', result: 'SUCCESS',
|
||||||
|
detail: { role: 'EDITOR', method: 'group_assignment' },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-21', timestamp: new Date(now - 4 * day).toISOString(),
|
||||||
|
username: 'system', category: 'INFRA', action: 'BACKUP',
|
||||||
|
target: 'db/primary', result: 'FAILURE',
|
||||||
|
detail: { error: 'disk_full', sizeBytes: 0 },
|
||||||
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-22', timestamp: new Date(now - 4 * day - 1 * hour).toISOString(),
|
||||||
|
username: 'alice', category: 'CONFIG', action: 'VIEW_CONFIG',
|
||||||
|
target: 'config/oidc', result: 'SUCCESS',
|
||||||
|
detail: { section: 'provider_settings' },
|
||||||
|
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-23', timestamp: new Date(now - 5 * day).toISOString(),
|
||||||
|
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_ROLE',
|
||||||
|
target: 'roles/OPERATOR', result: 'SUCCESS',
|
||||||
|
detail: { scope: 'custom', description: 'Pipeline operator' },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-24', timestamp: new Date(now - 5 * day - 3 * hour).toISOString(),
|
||||||
|
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
|
||||||
|
target: 'opensearch/agents', result: 'SUCCESS',
|
||||||
|
detail: { documents: 230, duration: '1.1s' },
|
||||||
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-25', timestamp: new Date(now - 6 * day).toISOString(),
|
||||||
|
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
|
||||||
|
target: 'users/bob', result: 'SUCCESS',
|
||||||
|
detail: { displayName: 'Bob Smith', roles: ['VIEWER'] },
|
||||||
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||||||
|
},
|
||||||
|
]
|
||||||
53
src/pages/Admin/OidcConfig/OidcConfig.module.css
Normal file
53
src/pages/Admin/OidcConfig/OidcConfig.module.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
.page {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noRoles {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addRoleRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleInput {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
188
src/pages/Admin/OidcConfig/OidcConfig.tsx
Normal file
188
src/pages/Admin/OidcConfig/OidcConfig.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AdminLayout } from '../Admin'
|
||||||
|
import { Button } from '../../../design-system/primitives/Button/Button'
|
||||||
|
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||||
|
import { Toggle } from '../../../design-system/primitives/Toggle/Toggle'
|
||||||
|
import { FormField } from '../../../design-system/primitives/FormField/FormField'
|
||||||
|
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||||
|
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
|
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
|
import styles from './OidcConfig.module.css'
|
||||||
|
|
||||||
|
interface OidcFormData {
|
||||||
|
enabled: boolean
|
||||||
|
autoSignup: boolean
|
||||||
|
issuerUri: string
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
rolesClaim: string
|
||||||
|
displayNameClaim: string
|
||||||
|
defaultRoles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DATA: OidcFormData = {
|
||||||
|
enabled: true,
|
||||||
|
autoSignup: true,
|
||||||
|
issuerUri: 'https://keycloak.example.com/realms/cameleer',
|
||||||
|
clientId: 'cameleer-app',
|
||||||
|
clientSecret: '••••••••••••',
|
||||||
|
rolesClaim: 'realm_access.roles',
|
||||||
|
displayNameClaim: 'name',
|
||||||
|
defaultRoles: ['USER', 'VIEWER'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OidcConfig() {
|
||||||
|
const [form, setForm] = useState<OidcFormData>(INITIAL_DATA)
|
||||||
|
const [newRole, setNewRole] = useState('')
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRole() {
|
||||||
|
const role = newRole.trim().toUpperCase()
|
||||||
|
if (role && !form.defaultRoles.includes(role)) {
|
||||||
|
update('defaultRoles', [...form.defaultRoles, role])
|
||||||
|
setNewRole('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRole(role: string) {
|
||||||
|
update('defaultRoles', form.defaultRoles.filter((r) => r !== role))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTest() {
|
||||||
|
toast({ title: 'Connection test', description: 'OIDC provider responded successfully.', variant: 'info' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
setForm({ ...INITIAL_DATA, enabled: false, issuerUri: '', clientId: '', clientSecret: '', defaultRoles: [] })
|
||||||
|
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="OIDC Configuration">
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<SectionHeader>Behavior</SectionHeader>
|
||||||
|
<div className={styles.toggleRow}>
|
||||||
|
<Toggle
|
||||||
|
label="Enabled"
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e) => update('enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.toggleRow}>
|
||||||
|
<Toggle
|
||||||
|
label="Auto Sign-Up"
|
||||||
|
checked={form.autoSignup}
|
||||||
|
onChange={(e) => update('autoSignup', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<SectionHeader>Provider Settings</SectionHeader>
|
||||||
|
<FormField label="Issuer URI" htmlFor="issuer">
|
||||||
|
<Input
|
||||||
|
id="issuer"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://idp.example.com/realms/my-realm"
|
||||||
|
value={form.issuerUri}
|
||||||
|
onChange={(e) => update('issuerUri', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Client ID" htmlFor="client-id">
|
||||||
|
<Input
|
||||||
|
id="client-id"
|
||||||
|
value={form.clientId}
|
||||||
|
onChange={(e) => update('clientId', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Client Secret" htmlFor="client-secret">
|
||||||
|
<Input
|
||||||
|
id="client-secret"
|
||||||
|
type="password"
|
||||||
|
value={form.clientSecret}
|
||||||
|
onChange={(e) => update('clientSecret', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<SectionHeader>Claim Mapping</SectionHeader>
|
||||||
|
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
|
||||||
|
<Input
|
||||||
|
id="roles-claim"
|
||||||
|
value={form.rolesClaim}
|
||||||
|
onChange={(e) => update('rolesClaim', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
|
||||||
|
<Input
|
||||||
|
id="name-claim"
|
||||||
|
value={form.displayNameClaim}
|
||||||
|
onChange={(e) => update('displayNameClaim', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<SectionHeader>Default Roles</SectionHeader>
|
||||||
|
<div className={styles.tagList}>
|
||||||
|
{form.defaultRoles.map((role) => (
|
||||||
|
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
|
||||||
|
))}
|
||||||
|
{form.defaultRoles.length === 0 && (
|
||||||
|
<span className={styles.noRoles}>No default roles configured</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.addRoleRow}>
|
||||||
|
<Input
|
||||||
|
placeholder="Add role..."
|
||||||
|
value={newRole}
|
||||||
|
onChange={(e) => setNewRole(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole() } }}
|
||||||
|
className={styles.roleInput}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<SectionHeader>Danger Zone</SectionHeader>
|
||||||
|
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
|
||||||
|
Delete OIDC Configuration
|
||||||
|
</Button>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
|
||||||
|
confirmText="delete oidc"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
312
src/pages/Admin/UserManagement/GroupsTab.tsx
Normal file
312
src/pages/Admin/UserManagement/GroupsTab.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
|
||||||
|
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||||
|
import { Button } from '../../../design-system/primitives/Button/Button'
|
||||||
|
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||||
|
import { Select } from '../../../design-system/primitives/Select/Select'
|
||||||
|
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||||
|
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
|
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||||
|
import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit'
|
||||||
|
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
||||||
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
|
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
||||||
|
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
|
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, getChildGroups, type MockGroup } from './rbacMocks'
|
||||||
|
import styles from './UserManagement.module.css'
|
||||||
|
|
||||||
|
export function GroupsTab() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [groups, setGroups] = useState(MOCK_GROUPS)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<MockGroup | null>(null)
|
||||||
|
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newParent, setNewParent] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return groups
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return groups.filter((g) => g.name.toLowerCase().includes(q))
|
||||||
|
}, [groups, search])
|
||||||
|
|
||||||
|
const selected = groups.find((g) => g.id === selectedId) ?? null
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
const newGroup: MockGroup = {
|
||||||
|
id: `grp-${Date.now()}`,
|
||||||
|
name: newName.trim(),
|
||||||
|
parentId: newParent || null,
|
||||||
|
builtIn: false,
|
||||||
|
directRoles: [],
|
||||||
|
memberUserIds: [],
|
||||||
|
}
|
||||||
|
setGroups((prev) => [...prev, newGroup])
|
||||||
|
setCreating(false)
|
||||||
|
setNewName(''); setNewParent('')
|
||||||
|
setSelectedId(newGroup.id)
|
||||||
|
toast({ title: 'Group created', description: newGroup.name, variant: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setGroups((prev) => prev.filter((g) => g.id !== deleteTarget.id))
|
||||||
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
toast({ title: 'Group deleted', description: deleteTarget.name, variant: 'warning' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroup(id: string, patch: Partial<MockGroup>) {
|
||||||
|
setGroups((prev) => prev.map((g) => g.id === id ? { ...g, ...patch } : g))
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateGroupName = newName.trim() !== '' && groups.some((g) => g.name.toLowerCase() === newName.trim().toLowerCase())
|
||||||
|
|
||||||
|
const children = selected ? groups.filter((g) => g.parentId === selected.id) : []
|
||||||
|
const members = selected ? MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)) : []
|
||||||
|
const parent = selected?.parentId ? groups.find((g) => g.id === selected.parentId) : null
|
||||||
|
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
|
||||||
|
.map((r) => ({ value: r.name, label: r.name }))
|
||||||
|
const availableMembers = MOCK_USERS.filter((u) => !selected || !u.directGroups.includes(selected.id))
|
||||||
|
.map((u) => ({ value: u.id, label: u.displayName }))
|
||||||
|
const availableChildGroups = groups.filter((g) => selected && g.id !== selected.id && g.parentId !== selected.id && !children.some((c) => c.id === g.id))
|
||||||
|
.map((g) => ({ value: g.id, label: g.name }))
|
||||||
|
|
||||||
|
const parentOptions = [
|
||||||
|
{ value: '', label: 'Top-level' },
|
||||||
|
...groups.filter((g) => g.id !== selectedId).map((g) => ({ value: g.id, label: g.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.splitPane}>
|
||||||
|
<div className={styles.listPane}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search groups..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onClear={() => setSearch('')}
|
||||||
|
className={styles.listHeaderSearch}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
||||||
|
+ Add group
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<Input placeholder="Group name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
|
{duplicateGroupName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Group name already exists</span>}
|
||||||
|
<Select
|
||||||
|
options={parentOptions}
|
||||||
|
value={newParent}
|
||||||
|
onChange={(e) => setNewParent(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className={styles.createFormActions}>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateGroupName}>Create</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.entityList} role="listbox" aria-label="Groups">
|
||||||
|
{filtered.map((group) => {
|
||||||
|
const groupChildren = groups.filter((g) => g.parentId === group.id)
|
||||||
|
const groupMembers = MOCK_USERS.filter((u) => u.directGroups.includes(group.id))
|
||||||
|
const groupParent = group.parentId ? groups.find((g) => g.id === group.parentId) : null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className={`${styles.entityItem} ${selectedId === group.id ? styles.entityItemSelected : ''}`}
|
||||||
|
onClick={() => setSelectedId(group.id)}
|
||||||
|
role="option"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={selectedId === group.id}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(group.id) } }}
|
||||||
|
>
|
||||||
|
<Avatar name={group.name} size="sm" />
|
||||||
|
<div className={styles.entityInfo}>
|
||||||
|
<div className={styles.entityName}>{group.name}</div>
|
||||||
|
<div className={styles.entityMeta}>
|
||||||
|
{groupParent ? `Child of ${groupParent.name}` : 'Top-level'}
|
||||||
|
{' · '}{groupChildren.length} children · {groupMembers.length} members
|
||||||
|
</div>
|
||||||
|
<div className={styles.entityTags}>
|
||||||
|
{group.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className={styles.emptySearch}>No groups match your search</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selected.name} size="lg" />
|
||||||
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>
|
||||||
|
{selected.builtIn ? selected.name : (
|
||||||
|
<InlineEdit
|
||||||
|
value={selected.name}
|
||||||
|
onSave={(v) => updateGroup(selected.id, { name: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailEmail}>
|
||||||
|
{parent ? `${parent.name} > ${selected.name}` : 'Top-level group'}
|
||||||
|
{selected.builtIn && ' (built-in)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
disabled={selected.builtIn}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{selected.id}</MonoText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parent && (
|
||||||
|
<>
|
||||||
|
<SectionHeader>Member of</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
<Tag label={parent.name} color="auto" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHeader>Members (direct)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{members.map((u) => (
|
||||||
|
<Tag
|
||||||
|
key={u.id}
|
||||||
|
label={u.displayName}
|
||||||
|
color="auto"
|
||||||
|
onRemove={() => {
|
||||||
|
// Remove this group from the user's directGroups
|
||||||
|
// Note: in mock data we can't easily update MOCK_USERS, so this is visual only
|
||||||
|
toast({ title: 'Member removed', description: u.displayName, variant: 'success' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{members.length === 0 && <span className={styles.inheritedNote}>(no members)</span>}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableMembers}
|
||||||
|
value={[]}
|
||||||
|
onChange={(ids) => {
|
||||||
|
toast({ title: `${ids.length} member(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children.length > 0 && (
|
||||||
|
<span className={styles.inheritedNote}>
|
||||||
|
+ all members of {children.map((c) => c.name).join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHeader>Child groups</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{children.map((c) => (
|
||||||
|
<Tag
|
||||||
|
key={c.id}
|
||||||
|
label={c.name}
|
||||||
|
color="success"
|
||||||
|
onRemove={() => {
|
||||||
|
updateGroup(c.id, { parentId: null })
|
||||||
|
toast({ title: 'Child group removed', description: c.name, variant: 'success' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{children.length === 0 && <span className={styles.inheritedNote}>(no child groups)</span>}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableChildGroups}
|
||||||
|
value={[]}
|
||||||
|
onChange={(ids) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
updateGroup(id, { parentId: selected!.id })
|
||||||
|
}
|
||||||
|
toast({ title: `${ids.length} child group(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Assigned roles</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{selected.directRoles.map((r) => (
|
||||||
|
<Tag
|
||||||
|
key={r}
|
||||||
|
label={r}
|
||||||
|
color="warning"
|
||||||
|
onRemove={() => {
|
||||||
|
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
|
||||||
|
if (memberCount > 0) {
|
||||||
|
setRemoveRoleTarget(r)
|
||||||
|
} else {
|
||||||
|
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
|
||||||
|
toast({ title: 'Role removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{selected.directRoles.length === 0 && <span className={styles.inheritedNote}>(no roles)</span>}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableRoles}
|
||||||
|
value={[]}
|
||||||
|
onChange={(roles) => {
|
||||||
|
updateGroup(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
||||||
|
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyDetail}>Select a group to view details</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
|
||||||
|
confirmText={deleteTarget?.name ?? ''}
|
||||||
|
/>
|
||||||
|
<AlertDialog
|
||||||
|
open={removeRoleTarget !== null}
|
||||||
|
onClose={() => setRemoveRoleTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (removeRoleTarget && selected) {
|
||||||
|
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== removeRoleTarget) })
|
||||||
|
toast({ title: 'Role removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
setRemoveRoleTarget(null)
|
||||||
|
}}
|
||||||
|
title="Remove role from group"
|
||||||
|
description={`Removing ${removeRoleTarget} from ${selected?.name} will affect ${members.length} member(s) who inherit this role. Continue?`}
|
||||||
|
confirmLabel="Remove"
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
src/pages/Admin/UserManagement/RolesTab.tsx
Normal file
227
src/pages/Admin/UserManagement/RolesTab.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
|
||||||
|
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||||
|
import { Button } from '../../../design-system/primitives/Button/Button'
|
||||||
|
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||||
|
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||||
|
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
|
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||||
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
|
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
|
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
|
||||||
|
import styles from './UserManagement.module.css'
|
||||||
|
|
||||||
|
export function RolesTab() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [roles, setRoles] = useState(MOCK_ROLES)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<MockRole | null>(null)
|
||||||
|
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newDesc, setNewDesc] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return roles
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return roles.filter((r) =>
|
||||||
|
r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}, [roles, search])
|
||||||
|
|
||||||
|
const selected = roles.find((r) => r.id === selectedId) ?? null
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
const newRole: MockRole = {
|
||||||
|
id: `role-${Date.now()}`,
|
||||||
|
name: newName.trim().toUpperCase(),
|
||||||
|
description: newDesc.trim(),
|
||||||
|
scope: 'custom',
|
||||||
|
system: false,
|
||||||
|
}
|
||||||
|
setRoles((prev) => [...prev, newRole])
|
||||||
|
setCreating(false)
|
||||||
|
setNewName(''); setNewDesc('')
|
||||||
|
setSelectedId(newRole.id)
|
||||||
|
toast({ title: 'Role created', description: newRole.name, variant: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setRoles((prev) => prev.filter((r) => r.id !== deleteTarget.id))
|
||||||
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
toast({ title: 'Role deleted', description: deleteTarget.name, variant: 'warning' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateRoleName = newName.trim() !== '' && roles.some((r) => r.name === newName.trim().toUpperCase())
|
||||||
|
|
||||||
|
// Role assignments
|
||||||
|
const assignedGroups = selected
|
||||||
|
? MOCK_GROUPS.filter((g) => g.directRoles.includes(selected.name))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const directUsers = selected
|
||||||
|
? MOCK_USERS.filter((u) => u.directRoles.includes(selected.name))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const effectivePrincipals = selected
|
||||||
|
? MOCK_USERS.filter((u) => getEffectiveRoles(u).some((r) => r.role === selected.name))
|
||||||
|
: []
|
||||||
|
|
||||||
|
function getAssignmentCount(role: MockRole): number {
|
||||||
|
const groups = MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name)).length
|
||||||
|
const users = MOCK_USERS.filter((u) => u.directRoles.includes(role.name)).length
|
||||||
|
return groups + users
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.splitPane}>
|
||||||
|
<div className={styles.listPane}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search roles..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onClear={() => setSearch('')}
|
||||||
|
className={styles.listHeaderSearch}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
||||||
|
+ Add role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
|
{duplicateRoleName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Role name already exists</span>}
|
||||||
|
<Input placeholder="Description" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
|
||||||
|
<div className={styles.createFormActions}>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateRoleName}>Create</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.entityList} role="listbox" aria-label="Roles">
|
||||||
|
{filtered.map((role) => (
|
||||||
|
<div
|
||||||
|
key={role.id}
|
||||||
|
className={`${styles.entityItem} ${selectedId === role.id ? styles.entityItemSelected : ''}`}
|
||||||
|
onClick={() => setSelectedId(role.id)}
|
||||||
|
role="option"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={selectedId === role.id}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(role.id) } }}
|
||||||
|
>
|
||||||
|
<Avatar name={role.name} size="sm" />
|
||||||
|
<div className={styles.entityInfo}>
|
||||||
|
<div className={styles.entityName}>
|
||||||
|
{role.name}
|
||||||
|
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.entityMeta}>
|
||||||
|
{role.description} · {getAssignmentCount(role)} assignments
|
||||||
|
</div>
|
||||||
|
<div className={styles.entityTags}>
|
||||||
|
{MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name))
|
||||||
|
.map((g) => <Badge key={g.id} label={g.name} color="success" />)}
|
||||||
|
{MOCK_USERS.filter((u) => u.directRoles.includes(role.name))
|
||||||
|
.map((u) => <Badge key={u.id} label={u.username} color="auto" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className={styles.emptySearch}>No roles match your search</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selected.name} size="lg" />
|
||||||
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>{selected.name}</div>
|
||||||
|
{selected.description && (
|
||||||
|
<div className={styles.detailEmail}>{selected.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!selected.system && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{selected.id}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Scope</span>
|
||||||
|
<span className={styles.metaValue}>{selected.scope}</span>
|
||||||
|
{selected.system && (
|
||||||
|
<>
|
||||||
|
<span className={styles.metaLabel}>Type</span>
|
||||||
|
<span className={styles.metaValue}>System role (read-only)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Assigned to groups</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{assignedGroups.map((g) => <Tag key={g.id} label={g.name} color="success" />)}
|
||||||
|
{assignedGroups.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{directUsers.map((u) => <Tag key={u.id} label={u.displayName} color="auto" />)}
|
||||||
|
{directUsers.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Effective principals</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{effectivePrincipals.map((u) => {
|
||||||
|
const isDirect = u.directRoles.includes(selected.name)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={u.id}
|
||||||
|
label={u.displayName}
|
||||||
|
color="auto"
|
||||||
|
variant={isDirect ? 'filled' : 'dashed'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{effectivePrincipals.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||||
|
</div>
|
||||||
|
{effectivePrincipals.some((u) => !u.directRoles.includes(selected.name)) && (
|
||||||
|
<span className={styles.inheritedNote}>
|
||||||
|
Dashed entries inherit this role through group membership
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyDetail}>Select a role to view details</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
|
||||||
|
confirmText={deleteTarget?.name ?? ''}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
229
src/pages/Admin/UserManagement/UserManagement.module.css
Normal file
229
src/pages/Admin/UserManagement/UserManagement.module.css
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
.splitPane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52fr 48fr;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 500px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listPane {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPane {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeaderSearch {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItemSelected {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityName {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityMeta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHeaderInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailEmail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 6px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaLabel {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValue {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectWrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyDetail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createForm {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createFormActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inheritedNote {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerBadge {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherited {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabContent {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptySearch {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.securitySection {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.passwordDots {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetForm {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetInput {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
28
src/pages/Admin/UserManagement/UserManagement.tsx
Normal file
28
src/pages/Admin/UserManagement/UserManagement.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import styles from './UserManagement.module.css'
|
||||||
|
import { AdminLayout } from '../Admin'
|
||||||
|
import { Tabs } from '../../../design-system/composites/Tabs/Tabs'
|
||||||
|
import { UsersTab } from './UsersTab'
|
||||||
|
import { GroupsTab } from './GroupsTab'
|
||||||
|
import { RolesTab } from './RolesTab'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ label: 'Users', value: 'users' },
|
||||||
|
{ label: 'Groups', value: 'groups' },
|
||||||
|
{ label: 'Roles', value: 'roles' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function UserManagement() {
|
||||||
|
const [tab, setTab] = useState('users')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="User Management">
|
||||||
|
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
{tab === 'users' && <UsersTab />}
|
||||||
|
{tab === 'groups' && <GroupsTab />}
|
||||||
|
{tab === 'roles' && <RolesTab />}
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
379
src/pages/Admin/UserManagement/UsersTab.tsx
Normal file
379
src/pages/Admin/UserManagement/UsersTab.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
|
||||||
|
import { Badge } from '../../../design-system/primitives/Badge/Badge'
|
||||||
|
import { Button } from '../../../design-system/primitives/Button/Button'
|
||||||
|
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||||
|
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
||||||
|
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
|
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||||
|
import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit'
|
||||||
|
import { RadioGroup, RadioItem } from '../../../design-system/primitives/Radio/Radio'
|
||||||
|
import { InfoCallout } from '../../../design-system/primitives/InfoCallout/InfoCallout'
|
||||||
|
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
||||||
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
|
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
||||||
|
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
|
import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks'
|
||||||
|
import styles from './UserManagement.module.css'
|
||||||
|
|
||||||
|
export function UsersTab() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [users, setUsers] = useState(MOCK_USERS)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<MockUser | null>(null)
|
||||||
|
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [newUsername, setNewUsername] = useState('')
|
||||||
|
const [newDisplay, setNewDisplay] = useState('')
|
||||||
|
const [newEmail, setNewEmail] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local')
|
||||||
|
const [resettingPassword, setResettingPassword] = useState(false)
|
||||||
|
const [newPw, setNewPw] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return users
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return users.filter((u) =>
|
||||||
|
u.displayName.toLowerCase().includes(q) ||
|
||||||
|
u.email.toLowerCase().includes(q) ||
|
||||||
|
u.username.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}, [users, search])
|
||||||
|
|
||||||
|
const selected = users.find((u) => u.id === selectedId) ?? null
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!newUsername.trim()) return
|
||||||
|
if (newProvider === 'local' && !newPassword.trim()) return
|
||||||
|
const newUser: MockUser = {
|
||||||
|
id: `usr-${Date.now()}`,
|
||||||
|
username: newUsername.trim(),
|
||||||
|
displayName: newDisplay.trim() || newUsername.trim(),
|
||||||
|
email: newEmail.trim(),
|
||||||
|
provider: newProvider,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
directRoles: [],
|
||||||
|
directGroups: [],
|
||||||
|
}
|
||||||
|
setUsers((prev) => [...prev, newUser])
|
||||||
|
setCreating(false)
|
||||||
|
setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword(''); setNewProvider('local')
|
||||||
|
setSelectedId(newUser.id)
|
||||||
|
setResettingPassword(false)
|
||||||
|
toast({ title: 'User created', description: newUser.displayName, variant: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
|
||||||
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
toast({ title: 'User deleted', description: deleteTarget.username, variant: 'warning' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUser(id: string, patch: Partial<MockUser>) {
|
||||||
|
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, ...patch } : u))
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateUsername = newUsername.trim() !== '' && users.some((u) => u.username.toLowerCase() === newUsername.trim().toLowerCase())
|
||||||
|
|
||||||
|
const effectiveRoles = selected ? getEffectiveRoles(selected) : []
|
||||||
|
const availableGroups = MOCK_GROUPS.filter((g) => !selected?.directGroups.includes(g.id))
|
||||||
|
.map((g) => ({ value: g.id, label: g.name }))
|
||||||
|
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
|
||||||
|
.map((r) => ({ value: r.name, label: r.name }))
|
||||||
|
|
||||||
|
function getUserGroupPath(user: MockUser): string {
|
||||||
|
if (user.directGroups.length === 0) return 'no groups'
|
||||||
|
const group = MOCK_GROUPS.find((g) => g.id === user.directGroups[0])
|
||||||
|
if (!group) return 'no groups'
|
||||||
|
const parent = group.parentId ? MOCK_GROUPS.find((g) => g.id === group.parentId) : null
|
||||||
|
return parent ? `${parent.name} > ${group.name}` : group.name
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.splitPane}>
|
||||||
|
<div className={styles.listPane}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search users..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onClear={() => setSearch('')}
|
||||||
|
className={styles.listHeaderSearch}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
||||||
|
+ Add user
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
|
||||||
|
<RadioItem value="local" label="Local" />
|
||||||
|
<RadioItem value="oidc" label="OIDC" />
|
||||||
|
</RadioGroup>
|
||||||
|
<div className={styles.createFormRow}>
|
||||||
|
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
||||||
|
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
|
||||||
|
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
||||||
|
{newProvider === 'local' && (
|
||||||
|
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||||
|
)}
|
||||||
|
{newProvider === 'oidc' && (
|
||||||
|
<InfoCallout variant="amber">
|
||||||
|
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
|
||||||
|
</InfoCallout>
|
||||||
|
)}
|
||||||
|
<div className={styles.createFormActions}>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newUsername.trim() || (newProvider === 'local' && !newPassword.trim()) || duplicateUsername}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.entityList} role="listbox" aria-label="Users">
|
||||||
|
{filtered.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
|
||||||
|
onClick={() => { setSelectedId(user.id); setResettingPassword(false) }}
|
||||||
|
role="option"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={selectedId === user.id}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(user.id); setResettingPassword(false) } }}
|
||||||
|
>
|
||||||
|
<Avatar name={user.displayName} size="sm" />
|
||||||
|
<div className={styles.entityInfo}>
|
||||||
|
<div className={styles.entityName}>
|
||||||
|
{user.displayName}
|
||||||
|
{user.provider !== 'local' && (
|
||||||
|
<Badge label={user.provider} color="running" variant="outlined" className={styles.providerBadge} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.entityMeta}>
|
||||||
|
{user.email} · {getUserGroupPath(user)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.entityTags}>
|
||||||
|
{user.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||||||
|
{user.directGroups.map((gId) => {
|
||||||
|
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
|
return g ? <Badge key={gId} label={g.name} color="success" /> : null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className={styles.emptySearch}>No users match your search</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selected.displayName} size="lg" />
|
||||||
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>
|
||||||
|
<InlineEdit
|
||||||
|
value={selected.displayName}
|
||||||
|
onSave={(v) => updateUser(selected.id, { displayName: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailEmail}>{selected.email}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
disabled={selected.username === 'hendrik'}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Status</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
<Tag label="Active" color="success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{selected.id}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Created</span>
|
||||||
|
<span className={styles.metaValue}>{new Date(selected.createdAt).toLocaleDateString()}</span>
|
||||||
|
<span className={styles.metaLabel}>Provider</span>
|
||||||
|
<span className={styles.metaValue}>{selected.provider}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Security</SectionHeader>
|
||||||
|
<div className={styles.securitySection}>
|
||||||
|
{selected.provider === 'local' ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.securityRow}>
|
||||||
|
<span className={styles.metaLabel}>Password</span>
|
||||||
|
<span className={styles.passwordDots}>••••••••</span>
|
||||||
|
{!resettingPassword && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setResettingPassword(true); setNewPw('') }}>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{resettingPassword && (
|
||||||
|
<div className={styles.resetForm}>
|
||||||
|
<Input
|
||||||
|
placeholder="New password"
|
||||||
|
type="password"
|
||||||
|
value={newPw}
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
className={styles.resetInput}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setResettingPassword(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => { setResettingPassword(false); toast({ title: 'Password updated', description: selected.username, variant: 'success' }) }}
|
||||||
|
disabled={!newPw.trim()}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.securityRow}>
|
||||||
|
<span className={styles.metaLabel}>Authentication</span>
|
||||||
|
<span className={styles.metaValue}>OIDC ({selected.provider})</span>
|
||||||
|
</div>
|
||||||
|
<InfoCallout variant="amber">
|
||||||
|
Password managed by the identity provider.
|
||||||
|
</InfoCallout>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Group membership (direct only)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{selected.directGroups.map((gId) => {
|
||||||
|
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
|
return g ? (
|
||||||
|
<Tag
|
||||||
|
key={gId}
|
||||||
|
label={g.name}
|
||||||
|
color="success"
|
||||||
|
onRemove={() => {
|
||||||
|
const group = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
|
if (group && group.directRoles.length > 0) {
|
||||||
|
setRemoveGroupTarget(gId)
|
||||||
|
} else {
|
||||||
|
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== gId) })
|
||||||
|
toast({ title: 'Group removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
{selected.directGroups.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(no groups)</span>
|
||||||
|
)}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableGroups}
|
||||||
|
value={[]}
|
||||||
|
onChange={(ids) => {
|
||||||
|
updateUser(selected.id, { directGroups: [...selected.directGroups, ...ids] })
|
||||||
|
toast({ title: `${ids.length} group(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Effective roles (direct + inherited)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{effectiveRoles.map(({ role, source }) =>
|
||||||
|
source === 'direct' ? (
|
||||||
|
<Tag
|
||||||
|
key={role}
|
||||||
|
label={role}
|
||||||
|
color="warning"
|
||||||
|
onRemove={() => {
|
||||||
|
updateUser(selected.id, { directRoles: selected.directRoles.filter((r) => r !== role) })
|
||||||
|
toast({ title: 'Role removed', description: role, variant: 'success' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
key={role}
|
||||||
|
label={`${role} ↑ ${source}`}
|
||||||
|
color="warning"
|
||||||
|
variant="dashed"
|
||||||
|
className={styles.inherited}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{effectiveRoles.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(no roles)</span>
|
||||||
|
)}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableRoles}
|
||||||
|
value={[]}
|
||||||
|
onChange={(roles) => {
|
||||||
|
updateUser(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
||||||
|
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{effectiveRoles.some((r) => r.source !== 'direct') && (
|
||||||
|
<span className={styles.inheritedNote}>
|
||||||
|
Roles with ↑ are inherited through group membership
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyDetail}>Select a user to view details</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
message={`Delete user "${deleteTarget?.username}"? This cannot be undone.`}
|
||||||
|
confirmText={deleteTarget?.username ?? ''}
|
||||||
|
/>
|
||||||
|
<AlertDialog
|
||||||
|
open={removeGroupTarget !== null}
|
||||||
|
onClose={() => setRemoveGroupTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (removeGroupTarget && selected) {
|
||||||
|
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== removeGroupTarget) })
|
||||||
|
toast({ title: 'Group removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
setRemoveGroupTarget(null)
|
||||||
|
}}
|
||||||
|
title="Remove group membership"
|
||||||
|
description={`Removing this group will also revoke inherited roles: ${MOCK_GROUPS.find((g) => g.id === removeGroupTarget)?.directRoles.join(', ') ?? ''}. Continue?`}
|
||||||
|
confirmLabel="Remove"
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
src/pages/Admin/UserManagement/rbacMocks.ts
Normal file
134
src/pages/Admin/UserManagement/rbacMocks.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
export interface MockUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
provider: 'local' | 'oidc'
|
||||||
|
createdAt: string
|
||||||
|
directRoles: string[]
|
||||||
|
directGroups: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockGroup {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
parentId: string | null
|
||||||
|
builtIn: boolean
|
||||||
|
directRoles: string[]
|
||||||
|
memberUserIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockRole {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
scope: 'system' | 'custom'
|
||||||
|
system: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_ROLES: MockRole[] = [
|
||||||
|
{ id: 'role-1', name: 'ADMIN', description: 'Full system access', scope: 'system', system: true },
|
||||||
|
{ id: 'role-2', name: 'USER', description: 'Standard user access', scope: 'system', system: true },
|
||||||
|
{ id: 'role-3', name: 'EDITOR', description: 'Can modify routes and configurations', scope: 'custom', system: false },
|
||||||
|
{ id: 'role-4', name: 'VIEWER', description: 'Read-only access to all resources', scope: 'custom', system: false },
|
||||||
|
{ id: 'role-5', name: 'OPERATOR', description: 'Pipeline operator — start, stop, monitor', scope: 'custom', system: false },
|
||||||
|
{ id: 'role-6', name: 'AUDITOR', description: 'Access to audit logs and compliance data', scope: 'custom', system: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOCK_GROUPS: MockGroup[] = [
|
||||||
|
{ id: 'grp-1', name: 'ADMINS', parentId: null, builtIn: true, directRoles: ['ADMIN'], memberUserIds: ['usr-1'] },
|
||||||
|
{ id: 'grp-2', name: 'Developers', parentId: null, builtIn: false, directRoles: ['EDITOR'], memberUserIds: ['usr-2', 'usr-3'] },
|
||||||
|
{ id: 'grp-3', name: 'Frontend', parentId: 'grp-2', builtIn: false, directRoles: ['VIEWER'], memberUserIds: ['usr-4'] },
|
||||||
|
{ id: 'grp-4', name: 'Operations', parentId: null, builtIn: false, directRoles: ['OPERATOR', 'VIEWER'], memberUserIds: ['usr-5', 'usr-6'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOCK_USERS: MockUser[] = [
|
||||||
|
{
|
||||||
|
id: 'usr-1', username: 'hendrik', displayName: 'Hendrik Siegeln',
|
||||||
|
email: 'hendrik@example.com', provider: 'local', createdAt: '2025-01-15T10:00:00Z',
|
||||||
|
directRoles: ['ADMIN'], directGroups: ['grp-1'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-2', username: 'alice', displayName: 'Alice Johnson',
|
||||||
|
email: 'alice@example.com', provider: 'oidc', createdAt: '2025-03-20T14:30:00Z',
|
||||||
|
directRoles: ['VIEWER'], directGroups: ['grp-2'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-3', username: 'bob', displayName: 'Bob Smith',
|
||||||
|
email: 'bob@example.com', provider: 'local', createdAt: '2025-04-10T09:00:00Z',
|
||||||
|
directRoles: [], directGroups: ['grp-2'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-4', username: 'carol', displayName: 'Carol Davis',
|
||||||
|
email: 'carol@example.com', provider: 'oidc', createdAt: '2025-06-01T11:15:00Z',
|
||||||
|
directRoles: [], directGroups: ['grp-3'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-5', username: 'dave', displayName: 'Dave Wilson',
|
||||||
|
email: 'dave@example.com', provider: 'local', createdAt: '2025-07-22T16:45:00Z',
|
||||||
|
directRoles: ['AUDITOR'], directGroups: ['grp-4'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-6', username: 'eve', displayName: 'Eve Martinez',
|
||||||
|
email: 'eve@example.com', provider: 'oidc', createdAt: '2025-09-05T08:20:00Z',
|
||||||
|
directRoles: [], directGroups: ['grp-4'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-7', username: 'frank', displayName: 'Frank Brown',
|
||||||
|
email: 'frank@example.com', provider: 'local', createdAt: '2025-11-12T13:00:00Z',
|
||||||
|
directRoles: ['USER'], directGroups: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-8', username: 'grace', displayName: 'Grace Lee',
|
||||||
|
email: 'grace@example.com', provider: 'oidc', createdAt: '2026-01-08T10:30:00Z',
|
||||||
|
directRoles: ['VIEWER', 'AUDITOR'], directGroups: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Resolve all roles for a user, including those inherited from groups */
|
||||||
|
export function getEffectiveRoles(user: MockUser): Array<{ role: string; source: 'direct' | string }> {
|
||||||
|
const result: Array<{ role: string; source: 'direct' | string }> = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
// Direct roles
|
||||||
|
for (const role of user.directRoles) {
|
||||||
|
result.push({ role, source: 'direct' })
|
||||||
|
seen.add(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk group chain for inherited roles
|
||||||
|
function walkGroup(groupId: string) {
|
||||||
|
const group = MOCK_GROUPS.find((g) => g.id === groupId)
|
||||||
|
if (!group) return
|
||||||
|
for (const role of group.directRoles) {
|
||||||
|
if (!seen.has(role)) {
|
||||||
|
result.push({ role, source: group.name })
|
||||||
|
seen.add(role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Walk parent group
|
||||||
|
if (group.parentId) walkGroup(group.parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const groupId of user.directGroups) {
|
||||||
|
walkGroup(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all groups in the chain (self + ancestors) for display */
|
||||||
|
export function getGroupChain(groupId: string): MockGroup[] {
|
||||||
|
const chain: MockGroup[] = []
|
||||||
|
let current = MOCK_GROUPS.find((g) => g.id === groupId)
|
||||||
|
while (current) {
|
||||||
|
chain.unshift(current)
|
||||||
|
current = current.parentId ? MOCK_GROUPS.find((g) => g.id === current!.parentId) : undefined
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get child groups of a given group */
|
||||||
|
export function getChildGroups(groupId: string): MockGroup[] {
|
||||||
|
return MOCK_GROUPS.filter((g) => g.parentId === groupId)
|
||||||
|
}
|
||||||
@@ -10,11 +10,27 @@
|
|||||||
/* Stat strip */
|
/* Stat strip */
|
||||||
.statStrip {
|
.statStrip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stat breakdown with colored dots */
|
||||||
|
.breakdown {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpLive { color: var(--success); display: inline-flex; align-items: center; gap: 3px; }
|
||||||
|
.bpStale { color: var(--warning); display: inline-flex; align-items: center; gap: 3px; }
|
||||||
|
.bpDead { color: var(--error); display: inline-flex; align-items: center; gap: 3px; }
|
||||||
|
|
||||||
|
.routesSuccess { color: var(--success); }
|
||||||
|
.routesWarning { color: var(--warning); }
|
||||||
|
.routesError { color: var(--error); }
|
||||||
|
|
||||||
/* Scope breadcrumb trail */
|
/* Scope breadcrumb trail */
|
||||||
.scopeTrail {
|
.scopeTrail {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -178,11 +194,6 @@
|
|||||||
box-shadow: inset 3px 0 0 var(--amber);
|
box-shadow: inset 3px 0 0 var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chart expansion row */
|
|
||||||
.chartRow td {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Instance fields */
|
/* Instance fields */
|
||||||
.instanceName {
|
.instanceName {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -211,17 +222,35 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instance expanded charts */
|
/* Detail panel content */
|
||||||
.instanceCharts {
|
.detailContent {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
}
|
||||||
background: var(--bg-raised);
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
.detailRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
padding: 4px 0;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailProgress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
.chartPanel {
|
.chartPanel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import styles from './AgentHealth.module.css'
|
import styles from './AgentHealth.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -11,12 +11,17 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
|||||||
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
|
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
|
||||||
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
||||||
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
||||||
|
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
||||||
|
import { ProgressBar } from '../../design-system/primitives/ProgressBar/ProgressBar'
|
||||||
|
|
||||||
|
// Global filters
|
||||||
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
||||||
@@ -28,13 +33,11 @@ import { agentEvents } from '../../mocks/agentEvents'
|
|||||||
type Scope =
|
type Scope =
|
||||||
| { level: 'all' }
|
| { level: 'all' }
|
||||||
| { level: 'app'; appId: string }
|
| { level: 'app'; appId: string }
|
||||||
| { level: 'instance'; appId: string; instanceId: string }
|
|
||||||
|
|
||||||
function useScope(): Scope {
|
function useScope(): Scope {
|
||||||
const { '*': rest } = useParams()
|
const { '*': rest } = useParams()
|
||||||
const segments = rest?.split('/').filter(Boolean) ?? []
|
const segments = rest?.split('/').filter(Boolean) ?? []
|
||||||
if (segments.length >= 2) return { level: 'instance', appId: segments[0], instanceId: segments[1] }
|
if (segments.length >= 1) return { level: 'app', appId: segments[0] }
|
||||||
if (segments.length === 1) return { level: 'app', appId: segments[0] }
|
|
||||||
return { level: 'all' }
|
return { level: 'all' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +106,8 @@ function buildBreadcrumb(scope: Scope) {
|
|||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
{ label: 'Agents', href: '/agents' },
|
{ label: 'Agents', href: '/agents' },
|
||||||
]
|
]
|
||||||
if (scope.level === 'app' || scope.level === 'instance') {
|
if (scope.level === 'app') {
|
||||||
crumbs.push({ label: scope.appId, href: `/agents/${scope.appId}` })
|
crumbs.push({ label: scope.appId })
|
||||||
}
|
|
||||||
if (scope.level === 'instance') {
|
|
||||||
crumbs.push({ label: scope.instanceId })
|
|
||||||
}
|
}
|
||||||
return crumbs
|
return crumbs
|
||||||
}
|
}
|
||||||
@@ -116,13 +116,14 @@ function buildBreadcrumb(scope: Scope) {
|
|||||||
|
|
||||||
export function AgentHealth() {
|
export function AgentHealth() {
|
||||||
const scope = useScope()
|
const scope = useScope()
|
||||||
const navigate = useNavigate()
|
const { isInTimeRange } = useGlobalFilters()
|
||||||
|
const [selectedInstance, setSelectedInstance] = useState<AgentHealthData | null>(null)
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false)
|
||||||
|
|
||||||
// Filter agents by scope
|
// Filter agents by scope
|
||||||
const filteredAgents = useMemo(() => {
|
const filteredAgents = useMemo(() => {
|
||||||
if (scope.level === 'all') return agents
|
if (scope.level === 'all') return agents
|
||||||
if (scope.level === 'app') return agents.filter((a) => a.appId === scope.appId)
|
return agents.filter((a) => a.appId === scope.appId)
|
||||||
return agents.filter((a) => a.appId === scope.appId && a.id === scope.instanceId)
|
|
||||||
}, [scope])
|
}, [scope])
|
||||||
|
|
||||||
const groups = useMemo(() => groupByApp(filteredAgents), [filteredAgents])
|
const groups = useMemo(() => groupByApp(filteredAgents), [filteredAgents])
|
||||||
@@ -134,18 +135,132 @@ export function AgentHealth() {
|
|||||||
const deadCount = filteredAgents.filter((a) => a.status === 'dead').length
|
const deadCount = filteredAgents.filter((a) => a.status === 'dead').length
|
||||||
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
||||||
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
||||||
|
const totalRoutes = filteredAgents.reduce((s, a) => s + a.totalRoutes, 0)
|
||||||
|
|
||||||
// Events are a global timeline feed — show all regardless of scope
|
// Filter events by global time range
|
||||||
const filteredEvents = agentEvents
|
const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp))
|
||||||
|
|
||||||
// Single instance for expanded charts
|
// Build trend data for selected instance
|
||||||
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
const trendData = selectedInstance ? buildTrendData(selectedInstance) : null
|
||||||
const trendData = singleInstance ? buildTrendData(singleInstance) : null
|
|
||||||
|
function handleInstanceClick(inst: AgentHealthData) {
|
||||||
|
setSelectedInstance(inst)
|
||||||
|
setPanelOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail panel tabs
|
||||||
|
const detailTabs = selectedInstance
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
value: 'overview',
|
||||||
|
content: (
|
||||||
|
<div className={styles.detailContent}>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Status</span>
|
||||||
|
<Badge
|
||||||
|
label={selectedInstance.status.toUpperCase()}
|
||||||
|
color={selectedInstance.status === 'live' ? 'success' : selectedInstance.status === 'stale' ? 'warning' : 'error'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Application</span>
|
||||||
|
<MonoText size="xs">{selectedInstance.appId}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Version</span>
|
||||||
|
<MonoText size="xs">{selectedInstance.version}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Uptime</span>
|
||||||
|
<MonoText size="xs">{selectedInstance.uptime}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Last Seen</span>
|
||||||
|
<MonoText size="xs">{selectedInstance.lastSeen}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Throughput</span>
|
||||||
|
<MonoText size="xs">{selectedInstance.tps.toFixed(1)}/s</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Errors</span>
|
||||||
|
<MonoText size="xs" className={selectedInstance.errorRate ? styles.instanceError : undefined}>
|
||||||
|
{selectedInstance.errorRate ?? '0 err/h'}
|
||||||
|
</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Routes</span>
|
||||||
|
<span>{selectedInstance.activeRoutes}/{selectedInstance.totalRoutes} active</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Memory</span>
|
||||||
|
<div className={styles.detailProgress}>
|
||||||
|
<ProgressBar
|
||||||
|
value={selectedInstance.memoryUsagePct}
|
||||||
|
variant={selectedInstance.memoryUsagePct > 85 ? 'error' : selectedInstance.memoryUsagePct > 70 ? 'warning' : 'success'}
|
||||||
|
/>
|
||||||
|
<MonoText size="xs">{selectedInstance.memoryUsagePct}%</MonoText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>CPU</span>
|
||||||
|
<div className={styles.detailProgress}>
|
||||||
|
<ProgressBar
|
||||||
|
value={selectedInstance.cpuUsagePct}
|
||||||
|
variant={selectedInstance.cpuUsagePct > 85 ? 'error' : selectedInstance.cpuUsagePct > 70 ? 'warning' : 'success'}
|
||||||
|
/>
|
||||||
|
<MonoText size="xs">{selectedInstance.cpuUsagePct}%</MonoText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Performance',
|
||||||
|
value: 'performance',
|
||||||
|
content: trendData ? (
|
||||||
|
<div className={styles.detailContent}>
|
||||||
|
<div className={styles.chartPanel}>
|
||||||
|
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||||
|
<LineChart
|
||||||
|
series={[{ label: 'tps', data: trendData.throughput }]}
|
||||||
|
height={160}
|
||||||
|
width={360}
|
||||||
|
yLabel="msg/s"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartPanel}>
|
||||||
|
<div className={styles.chartTitle}>Error Rate (err/h)</div>
|
||||||
|
<LineChart
|
||||||
|
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
|
||||||
|
height={160}
|
||||||
|
width={360}
|
||||||
|
yLabel="err/h"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
const isFullWidth = scope.level !== 'all'
|
const isFullWidth = scope.level !== 'all'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
<AppShell
|
||||||
|
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
|
||||||
|
detail={
|
||||||
|
selectedInstance ? (
|
||||||
|
<DetailPanel
|
||||||
|
open={panelOpen}
|
||||||
|
onClose={() => setPanelOpen(false)}
|
||||||
|
title={selectedInstance.name}
|
||||||
|
tabs={detailTabs}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={buildBreadcrumb(scope)}
|
breadcrumb={buildBreadcrumb(scope)}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
@@ -155,36 +270,61 @@ export function AgentHealth() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{/* Stat strip */}
|
{/* Stat strip */}
|
||||||
<div className={styles.statStrip}>
|
<div className={styles.statStrip}>
|
||||||
<StatCard label="Total Instances" value={String(totalInstances)} />
|
<StatCard
|
||||||
<StatCard label="Live" value={String(liveCount)} accent="success" />
|
label="Total Agents"
|
||||||
<StatCard label="Stale" value={String(staleCount)} accent={staleCount > 0 ? 'warning' : undefined} />
|
value={String(totalInstances)}
|
||||||
<StatCard label="Dead" value={String(deadCount)} accent={deadCount > 0 ? 'error' : undefined} />
|
accent={deadCount > 0 ? 'warning' : 'amber'}
|
||||||
<StatCard label="Total TPS" value={`${totalTps.toFixed(1)}/s`} />
|
detail={
|
||||||
<StatCard label="Active Routes" value={String(totalActiveRoutes)} />
|
<span className={styles.breakdown}>
|
||||||
|
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
|
||||||
|
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
|
||||||
|
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Applications"
|
||||||
|
value={String(groups.length)}
|
||||||
|
accent="running"
|
||||||
|
detail={
|
||||||
|
<span className={styles.breakdown}>
|
||||||
|
<span className={styles.bpLive}><StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy</span>
|
||||||
|
<span className={styles.bpStale}><StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded</span>
|
||||||
|
<span className={styles.bpDead}><StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Active Routes"
|
||||||
|
value={<span className={styles[totalActiveRoutes === 0 ? 'routesError' : totalActiveRoutes < totalRoutes ? 'routesWarning' : 'routesSuccess']}>{totalActiveRoutes}/{totalRoutes}</span>}
|
||||||
|
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
|
||||||
|
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total TPS"
|
||||||
|
value={totalTps.toFixed(1)}
|
||||||
|
accent="amber"
|
||||||
|
detail="msg/s"
|
||||||
|
trend="up"
|
||||||
|
trendValue="4.2%"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Dead"
|
||||||
|
value={String(deadCount)}
|
||||||
|
accent={deadCount > 0 ? 'error' : 'success'}
|
||||||
|
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scope breadcrumb trail */}
|
{/* Scope trail + badges */}
|
||||||
{scope.level !== 'all' && (
|
|
||||||
<div className={styles.scopeTrail}>
|
<div className={styles.scopeTrail}>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
{scope.level !== 'all' && (
|
||||||
{scope.level === 'instance' && (
|
|
||||||
<>
|
<>
|
||||||
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<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
|
<Badge
|
||||||
label={`${liveCount}/${totalInstances} live`}
|
label={`${liveCount}/${totalInstances} live`}
|
||||||
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
|
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
|
||||||
@@ -236,14 +376,13 @@ export function AgentHealth() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{group.instances.map((inst) => (
|
{group.instances.map((inst) => (
|
||||||
<>
|
|
||||||
<tr
|
<tr
|
||||||
key={inst.id}
|
key={inst.id}
|
||||||
className={[
|
className={[
|
||||||
styles.instanceRow,
|
styles.instanceRow,
|
||||||
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '',
|
selectedInstance?.id === inst.id && panelOpen ? styles.instanceRowActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate(`/agents/${inst.appId}/${inst.id}`)}
|
onClick={() => handleInstanceClick(inst)}
|
||||||
>
|
>
|
||||||
<td className={styles.tdStatus}>
|
<td className={styles.tdStatus}>
|
||||||
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
||||||
@@ -279,35 +418,6 @@ export function AgentHealth() {
|
|||||||
</MonoText>
|
</MonoText>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Expanded charts for single instance */}
|
|
||||||
{singleInstance?.id === inst.id && trendData && (
|
|
||||||
<tr key={`${inst.id}-charts`} className={styles.chartRow}>
|
|
||||||
<td colSpan={7}>
|
|
||||||
<div className={styles.instanceCharts}>
|
|
||||||
<div className={styles.chartPanel}>
|
|
||||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
|
||||||
<LineChart
|
|
||||||
series={[{ label: 'tps', data: trendData.throughput }]}
|
|
||||||
height={160}
|
|
||||||
width={480}
|
|
||||||
yLabel="msg/s"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.chartPanel}>
|
|
||||||
<div className={styles.chartTitle}>Error Rate (err/h)</div>
|
|
||||||
<LineChart
|
|
||||||
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
|
|
||||||
height={160}
|
|
||||||
width={480}
|
|
||||||
yLabel="err/h"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export function ApiDocs() {
|
|||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[{ label: 'API Documentation' }]}
|
breadcrumb={[{ label: 'API Documentation' }]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function AppDetail() {
|
|||||||
{ label: id ?? '' },
|
{ label: id ?? '' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -69,14 +69,9 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.routeGroup {
|
/* Application column */
|
||||||
font-size: 10px;
|
.appName {
|
||||||
color: var(--text-muted);
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Customer text */
|
|
||||||
.customerText {
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +141,46 @@
|
|||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail panel: overview tab */
|
/* Detail panel sections */
|
||||||
.overviewTab {
|
.panelSection {
|
||||||
padding: 16px;
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelSection:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelSectionTitle {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelSectionMeta {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overview grid */
|
||||||
|
.overviewGrid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overviewRow {
|
.overviewRow {
|
||||||
@@ -166,17 +195,17 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
width: 100px;
|
width: 90px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Error block */
|
||||||
.errorBlock {
|
.errorBlock {
|
||||||
background: var(--error-bg);
|
background: var(--error-bg);
|
||||||
border: 1px solid var(--error-border);
|
border: 1px solid var(--error-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorClass {
|
.errorClass {
|
||||||
@@ -184,7 +213,7 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorMessage {
|
.errorMessage {
|
||||||
@@ -192,40 +221,45 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
|
||||||
|
|
||||||
/* Detail panel: processors tab */
|
|
||||||
.processorsTab {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Detail panel: exchange tab */
|
|
||||||
.exchangeTab {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Detail panel: error tab */
|
|
||||||
.errorTab {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyTabMsg {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorPre {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--error);
|
|
||||||
background: var(--error-bg);
|
|
||||||
border: 1px solid var(--error-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 12px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
line-height: 1.5;
|
}
|
||||||
margin-top: 8px;
|
|
||||||
|
/* Inspect exchange icon in table */
|
||||||
|
.inspectLink {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspectLink:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Open full details link in panel */
|
||||||
|
.openDetailLink {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--amber);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openDetailLink:hover {
|
||||||
|
color: var(--amber-deep);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -8,15 +8,13 @@ import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
import { FilterBar } from '../../design-system/composites/FilterBar/FilterBar'
|
|
||||||
import type { ActiveFilter } from '../../design-system/composites/FilterBar/FilterBar'
|
|
||||||
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
||||||
import type { Column } from '../../design-system/composites/DataTable/types'
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||||
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
||||||
import { CommandPalette } from '../../design-system/composites/CommandPalette/CommandPalette'
|
|
||||||
import type { SearchResult } from '../../design-system/composites/CommandPalette/types'
|
|
||||||
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
|
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
|
||||||
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
||||||
|
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
||||||
@@ -24,12 +22,16 @@ import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
|||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
|
|
||||||
|
// Global filters
|
||||||
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
import { routes } from '../../mocks/routes'
|
|
||||||
import { agents } from '../../mocks/agents'
|
|
||||||
import { kpiMetrics } from '../../mocks/metrics'
|
import { kpiMetrics } from '../../mocks/metrics'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||||
|
|
||||||
|
// Route → Application lookup
|
||||||
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
@@ -39,7 +41,13 @@ function formatDuration(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
const y = date.getFullYear()
|
||||||
|
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
const h = String(date.getHours()).padStart(2, '0')
|
||||||
|
const mi = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const s = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
|
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
|
||||||
@@ -60,8 +68,8 @@ function statusLabel(status: Exchange['status']): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Table columns ────────────────────────────────────────────────────────────
|
// ─── Table columns (base, without navigate action) ──────────────────────────
|
||||||
const COLUMNS: Column<Exchange>[] = [
|
const BASE_COLUMNS: Column<Exchange>[] = [
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
@@ -78,25 +86,23 @@ const COLUMNS: Column<Exchange>[] = [
|
|||||||
header: 'Route',
|
header: 'Route',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<div>
|
<span className={styles.routeName}>{row.route}</span>
|
||||||
<div className={styles.routeName}>{row.route}</div>
|
|
||||||
<div className={styles.routeGroup}>{row.routeGroup}</div>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'orderId',
|
key: 'routeGroup',
|
||||||
header: 'Order ID',
|
header: 'Application',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<MonoText size="sm">{row.orderId}</MonoText>
|
<span className={styles.appName}>{ROUTE_TO_APP.get(row.route) ?? row.routeGroup}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'customer',
|
key: 'id',
|
||||||
header: 'Customer',
|
header: 'Exchange ID',
|
||||||
|
sortable: true,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<MonoText size="xs" className={styles.customerText}>{row.customer}</MonoText>
|
<MonoText size="xs">{row.id}</MonoText>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -137,58 +143,6 @@ function durationClass(ms: number, status: Exchange['status']): string {
|
|||||||
return styles.durBreach
|
return styles.durBreach
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Build CommandPalette search data ────────────────────────────────────────
|
|
||||||
function buildSearchData(
|
|
||||||
exs: Exchange[],
|
|
||||||
rts: typeof routes,
|
|
||||||
ags: typeof agents,
|
|
||||||
): SearchResult[] {
|
|
||||||
const results: SearchResult[] = []
|
|
||||||
|
|
||||||
for (const exec of exs) {
|
|
||||||
results.push({
|
|
||||||
id: exec.id,
|
|
||||||
category: 'exchange',
|
|
||||||
title: `${exec.orderId} — ${exec.route}`,
|
|
||||||
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
|
|
||||||
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
|
|
||||||
timestamp: formatTimestamp(exec.timestamp),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const route of rts) {
|
|
||||||
results.push({
|
|
||||||
id: route.id,
|
|
||||||
category: 'route',
|
|
||||||
title: route.name,
|
|
||||||
badges: [{ label: route.group }],
|
|
||||||
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const agent of ags) {
|
|
||||||
results.push({
|
|
||||||
id: agent.id,
|
|
||||||
category: 'agent',
|
|
||||||
title: agent.name,
|
|
||||||
badges: [{ label: agent.status }],
|
|
||||||
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStatusFilters(exs: Exchange[]) {
|
|
||||||
return [
|
|
||||||
{ label: 'All', value: 'all', count: exs.length },
|
|
||||||
{ label: 'OK', value: 'completed', count: exs.filter((e) => e.status === 'completed').length, color: 'success' as const },
|
|
||||||
{ label: 'Warn', value: 'warning', count: exs.filter((e) => e.status === 'warning').length },
|
|
||||||
{ label: 'Error', value: 'failed', count: exs.filter((e) => e.status === 'failed').length, color: 'error' as const },
|
|
||||||
{ label: 'Running', value: 'running', count: exs.filter((e) => e.status === 'running').length, color: 'running' as const },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHORTCUTS = [
|
const SHORTCUTS = [
|
||||||
{ keys: 'Ctrl+K', label: 'Search' },
|
{ keys: 'Ctrl+K', label: 'Search' },
|
||||||
{ keys: '↑↓', label: 'Navigate rows' },
|
{ keys: '↑↓', label: 'Navigate rows' },
|
||||||
@@ -198,13 +152,36 @@ const SHORTCUTS = [
|
|||||||
|
|
||||||
// ─── Dashboard component ──────────────────────────────────────────────────────
|
// ─── Dashboard component ──────────────────────────────────────────────────────
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { id: appId } = useParams<{ id: string }>()
|
const { id: appId, routeId } = useParams<{ id: string; routeId: string }>()
|
||||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([])
|
const navigate = useNavigate()
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||||
const [panelOpen, setPanelOpen] = useState(false)
|
const [panelOpen, setPanelOpen] = useState(false)
|
||||||
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
|
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
||||||
|
// Build columns with inspect action as second column
|
||||||
|
const COLUMNS: Column<Exchange>[] = useMemo(() => {
|
||||||
|
const inspectCol: Column<Exchange> = {
|
||||||
|
key: 'correlationId' as keyof Exchange,
|
||||||
|
header: '',
|
||||||
|
width: '36px',
|
||||||
|
render: (_, row) => (
|
||||||
|
<button
|
||||||
|
className={styles.inspectLink}
|
||||||
|
title="Inspect exchange"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/exchanges/${row.id}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↗
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
const [statusCol, ...rest] = BASE_COLUMNS
|
||||||
|
return [statusCol, inspectCol, ...rest]
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const { isInTimeRange, statusFilters } = useGlobalFilters()
|
||||||
|
|
||||||
// Build set of route IDs belonging to the selected app (if any)
|
// Build set of route IDs belonging to the selected app (if any)
|
||||||
const appRouteIds = useMemo(() => {
|
const appRouteIds = useMemo(() => {
|
||||||
@@ -214,55 +191,27 @@ export function Dashboard() {
|
|||||||
return new Set(app.routes.map((r) => r.id))
|
return new Set(app.routes.map((r) => r.id))
|
||||||
}, [appId])
|
}, [appId])
|
||||||
|
|
||||||
const selectedApp = appId ? SIDEBAR_APPS.find((a) => a.id === appId) : null
|
// Scope all data to the selected app (and optionally route)
|
||||||
|
|
||||||
// Scope all data to the selected app
|
|
||||||
const scopedExchanges = useMemo(() => {
|
const scopedExchanges = useMemo(() => {
|
||||||
|
if (routeId) return exchanges.filter((e) => e.route === routeId)
|
||||||
if (!appRouteIds) return exchanges
|
if (!appRouteIds) return exchanges
|
||||||
return exchanges.filter((e) => appRouteIds.has(e.route))
|
return exchanges.filter((e) => appRouteIds.has(e.route))
|
||||||
}, [appRouteIds])
|
}, [appRouteIds, routeId])
|
||||||
|
|
||||||
const scopedRoutes = useMemo(() => {
|
// Filter exchanges (scoped + global filters)
|
||||||
if (!appRouteIds) return routes
|
|
||||||
return routes.filter((r) => appRouteIds.has(r.id))
|
|
||||||
}, [appRouteIds])
|
|
||||||
|
|
||||||
const scopedAgents = useMemo(() => {
|
|
||||||
if (!selectedApp) return agents
|
|
||||||
const agentIds = new Set(selectedApp.agents.map((a) => a.id))
|
|
||||||
return agents.filter((a) => agentIds.has(a.id))
|
|
||||||
}, [selectedApp])
|
|
||||||
|
|
||||||
// Filter exchanges (scoped + user filters)
|
|
||||||
const filteredExchanges = useMemo(() => {
|
const filteredExchanges = useMemo(() => {
|
||||||
let data = scopedExchanges
|
let data = scopedExchanges
|
||||||
|
|
||||||
const statusFilter = activeFilters.find((f) =>
|
// Time range filter
|
||||||
['completed', 'failed', 'running', 'warning', 'all'].includes(f.value),
|
data = data.filter((e) => isInTimeRange(e.timestamp))
|
||||||
)
|
|
||||||
if (statusFilter && statusFilter.value !== 'all') {
|
|
||||||
data = data.filter((e) => e.status === statusFilter.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search.trim()) {
|
// Status filter
|
||||||
const q = search.toLowerCase()
|
if (statusFilters.size > 0) {
|
||||||
data = data.filter(
|
data = data.filter((e) => statusFilters.has(e.status))
|
||||||
(e) =>
|
|
||||||
e.orderId.toLowerCase().includes(q) ||
|
|
||||||
e.route.toLowerCase().includes(q) ||
|
|
||||||
e.customer.toLowerCase().includes(q) ||
|
|
||||||
e.correlationId.toLowerCase().includes(q) ||
|
|
||||||
(e.errorMessage?.toLowerCase().includes(q) ?? false),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}, [activeFilters, search, scopedExchanges])
|
}, [scopedExchanges, isInTimeRange, statusFilters])
|
||||||
|
|
||||||
const searchData = useMemo(
|
|
||||||
() => buildSearchData(scopedExchanges, scopedRoutes, scopedAgents),
|
|
||||||
[scopedExchanges, scopedRoutes, scopedAgents],
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleRowClick(row: Exchange) {
|
function handleRowClick(row: Exchange) {
|
||||||
setSelectedId(row.id)
|
setSelectedId(row.id)
|
||||||
@@ -276,98 +225,33 @@ export function Dashboard() {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build detail panel tabs for selected exchange
|
// Map processor types to RouteNode types
|
||||||
const detailTabs = selectedExchange
|
function toRouteNodeType(procType: string): RouteNode['type'] {
|
||||||
? [
|
switch (procType) {
|
||||||
{
|
case 'consumer': return 'from'
|
||||||
label: 'Overview',
|
case 'transform': return 'process'
|
||||||
value: 'overview',
|
case 'enrich': return 'process'
|
||||||
content: (
|
default: return procType as RouteNode['type']
|
||||||
<div className={styles.overviewTab}>
|
}
|
||||||
<div className={styles.overviewRow}>
|
}
|
||||||
<span className={styles.overviewLabel}>Order ID</span>
|
|
||||||
<MonoText size="sm">{selectedExchange.orderId}</MonoText>
|
// Build RouteFlow nodes from exchange processors
|
||||||
</div>
|
const routeNodes: RouteNode[] = selectedExchange
|
||||||
<div className={styles.overviewRow}>
|
? selectedExchange.processors.map((p) => ({
|
||||||
<span className={styles.overviewLabel}>Route</span>
|
name: p.name,
|
||||||
<span>{selectedExchange.route}</span>
|
type: toRouteNodeType(p.type),
|
||||||
</div>
|
durationMs: p.durationMs,
|
||||||
<div className={styles.overviewRow}>
|
status: p.status,
|
||||||
<span className={styles.overviewLabel}>Status</span>
|
}))
|
||||||
<span className={styles.statusCell}>
|
|
||||||
<StatusDot variant={statusToVariant(selectedExchange.status)} />
|
|
||||||
<span>{statusLabel(selectedExchange.status)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Duration</span>
|
|
||||||
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Customer</span>
|
|
||||||
<MonoText size="sm">{selectedExchange.customer}</MonoText>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Agent</span>
|
|
||||||
<MonoText size="sm">{selectedExchange.agent}</MonoText>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Correlation ID</span>
|
|
||||||
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
|
|
||||||
</div>
|
|
||||||
<div className={styles.overviewRow}>
|
|
||||||
<span className={styles.overviewLabel}>Timestamp</span>
|
|
||||||
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
|
|
||||||
</div>
|
|
||||||
{selectedExchange.errorMessage && (
|
|
||||||
<div className={styles.errorBlock}>
|
|
||||||
<div className={styles.errorClass}>{selectedExchange.errorClass}</div>
|
|
||||||
<div className={styles.errorMessage}>{selectedExchange.errorMessage}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Processors',
|
|
||||||
value: 'processors',
|
|
||||||
content: (
|
|
||||||
<div className={styles.processorsTab}>
|
|
||||||
<ProcessorTimeline
|
|
||||||
processors={selectedExchange.processors}
|
|
||||||
totalMs={selectedExchange.durationMs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Exchange',
|
|
||||||
value: 'exchange',
|
|
||||||
content: (
|
|
||||||
<div className={styles.exchangeTab}>
|
|
||||||
<div className={styles.emptyTabMsg}>Exchange snapshot not available in mock mode.</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Error',
|
|
||||||
value: 'error',
|
|
||||||
content: (
|
|
||||||
<div className={styles.errorTab}>
|
|
||||||
{selectedExchange.errorMessage ? (
|
|
||||||
<>
|
|
||||||
<div className={styles.errorClass}>{selectedExchange.errorClass}</div>
|
|
||||||
<pre className={styles.errorPre}>{selectedExchange.errorMessage}</pre>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className={styles.emptyTabMsg}>No error for this exchange.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
// Collect errors from processors
|
||||||
|
const processorErrors = selectedExchange
|
||||||
|
? selectedExchange.processors.filter((p) => p.status === 'fail')
|
||||||
|
: []
|
||||||
|
const hasExchangeError = selectedExchange?.errorMessage != null
|
||||||
|
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
sidebar={
|
sidebar={
|
||||||
@@ -379,20 +263,107 @@ export function Dashboard() {
|
|||||||
open={panelOpen}
|
open={panelOpen}
|
||||||
onClose={() => setPanelOpen(false)}
|
onClose={() => setPanelOpen(false)}
|
||||||
title={`${selectedExchange.orderId} — ${selectedExchange.route}`}
|
title={`${selectedExchange.orderId} — ${selectedExchange.route}`}
|
||||||
tabs={detailTabs}
|
>
|
||||||
|
{/* Link to full detail page */}
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<button
|
||||||
|
className={styles.openDetailLink}
|
||||||
|
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
||||||
|
>
|
||||||
|
Open full details →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<div className={styles.panelSectionTitle}>Overview</div>
|
||||||
|
<div className={styles.overviewGrid}>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Status</span>
|
||||||
|
<span className={styles.statusCell}>
|
||||||
|
<StatusDot variant={statusToVariant(selectedExchange.status)} />
|
||||||
|
<span>{statusLabel(selectedExchange.status)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Duration</span>
|
||||||
|
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Route</span>
|
||||||
|
<span>{selectedExchange.route}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Customer</span>
|
||||||
|
<MonoText size="sm">{selectedExchange.customer}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Agent</span>
|
||||||
|
<MonoText size="sm">{selectedExchange.agent}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Correlation</span>
|
||||||
|
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewRow}>
|
||||||
|
<span className={styles.overviewLabel}>Timestamp</span>
|
||||||
|
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errors */}
|
||||||
|
{totalErrors > 0 && (
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<div className={styles.panelSectionTitle}>
|
||||||
|
Errors
|
||||||
|
{totalErrors > 1 && (
|
||||||
|
<Badge label={`+${totalErrors - 1} more`} color="error" variant="outlined" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.errorBlock}>
|
||||||
|
<div className={styles.errorClass}>
|
||||||
|
{selectedExchange.errorClass ?? processorErrors[0]?.name}
|
||||||
|
</div>
|
||||||
|
<div className={styles.errorMessage}>
|
||||||
|
{selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Route Flow */}
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||||
|
<RouteFlow nodes={routeNodes} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processor Timeline */}
|
||||||
|
<div className={styles.panelSection}>
|
||||||
|
<div className={styles.panelSectionTitle}>
|
||||||
|
Processor Timeline
|
||||||
|
<span className={styles.panelSectionMeta}>{formatDuration(selectedExchange.durationMs)}</span>
|
||||||
|
</div>
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={selectedExchange.processors}
|
||||||
|
totalMs={selectedExchange.durationMs}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</DetailPanel>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={appId
|
breadcrumb={
|
||||||
|
routeId
|
||||||
|
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
|
||||||
|
: appId
|
||||||
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
|
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
|
||||||
: [{ label: 'Applications' }]
|
: [{ label: 'Applications' }]
|
||||||
}
|
}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
onSearchClick={() => setPaletteOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
@@ -414,17 +385,6 @@ export function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
|
||||||
<FilterBar
|
|
||||||
filters={buildStatusFilters(scopedExchanges)}
|
|
||||||
activeFilters={activeFilters}
|
|
||||||
onFilterChange={setActiveFilters}
|
|
||||||
searchPlaceholder="Search by Order ID, correlation ID, error message..."
|
|
||||||
searchValue={search}
|
|
||||||
onSearchChange={setSearch}
|
|
||||||
className={styles.filterBar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Exchanges table */}
|
{/* Exchanges table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
@@ -443,6 +403,7 @@ export function Dashboard() {
|
|||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
sortable
|
sortable
|
||||||
|
flush
|
||||||
rowAccent={handleRowAccent}
|
rowAccent={handleRowAccent}
|
||||||
expandedContent={(row) =>
|
expandedContent={(row) =>
|
||||||
row.errorMessage ? (
|
row.errorMessage ? (
|
||||||
@@ -459,15 +420,6 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Command palette */}
|
|
||||||
<CommandPalette
|
|
||||||
open={paletteOpen}
|
|
||||||
onClose={() => setPaletteOpen(false)}
|
|
||||||
onSelect={() => setPaletteOpen(false)}
|
|
||||||
data={searchData}
|
|
||||||
onOpen={() => setPaletteOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Shortcuts bar */}
|
{/* Shortcuts bar */}
|
||||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Exchange header card */
|
/* ==========================================================================
|
||||||
|
EXCHANGE HEADER CARD
|
||||||
|
========================================================================== */
|
||||||
.exchangeHeader {
|
.exchangeHeader {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -88,17 +90,85 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section layout */
|
/* ==========================================================================
|
||||||
.section {
|
CORRELATION CHAIN
|
||||||
|
========================================================================== */
|
||||||
|
.correlationChain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chainLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chainNode {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chainNode:hover {
|
||||||
|
border-color: var(--text-faint);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chainNodeCurrent {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
border-color: var(--amber-light);
|
||||||
|
color: var(--amber-deep);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chainNodeSuccess {
|
||||||
|
border-left: 3px solid var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chainNodeError {
|
||||||
|
border-left: 3px solid var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chainNodeRunning {
|
||||||
|
border-left: 3px solid var(--running);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chainNodeWarning {
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
TIMELINE SECTION
|
||||||
|
========================================================================== */
|
||||||
|
.timelineSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeader {
|
.timelineHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -106,159 +176,255 @@
|
|||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.timelineTitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
|
||||||
|
|
||||||
.sectionMeta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Timeline wrapper */
|
|
||||||
.timelineWrap {
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inspector steps */
|
|
||||||
.inspectorSteps {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepCollapsible {
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepCollapsible:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepTitle {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepIndex {
|
.procCount {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
flex-shrink: 0;
|
font-size: 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.stepOk {
|
|
||||||
background: var(--success-bg);
|
|
||||||
color: var(--success);
|
|
||||||
border: 1px solid var(--success-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepSlow {
|
|
||||||
background: var(--warning-bg);
|
|
||||||
color: var(--warning);
|
|
||||||
border: 1px solid var(--warning-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepFail {
|
|
||||||
background: var(--error-bg);
|
|
||||||
color: var(--error);
|
|
||||||
border: 1px solid var(--error-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepName {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: var(--font-mono);
|
padding: 1px 8px;
|
||||||
color: var(--text-primary);
|
border-radius: 10px;
|
||||||
flex: 1;
|
background: var(--bg-inset);
|
||||||
}
|
|
||||||
|
|
||||||
.stepDuration {
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: auto;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step body (two-column layout) */
|
.timelineToggle {
|
||||||
.stepBody {
|
display: inline-flex;
|
||||||
display: grid;
|
gap: 0;
|
||||||
grid-template-columns: 1fr 2fr;
|
border: 1px solid var(--border-subtle);
|
||||||
gap: 12px;
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleBtn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleBtnActive {
|
||||||
|
background: var(--amber);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleBtnActive:hover {
|
||||||
|
background: var(--amber-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelineBody {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepPanel {
|
/* ==========================================================================
|
||||||
|
DETAIL SPLIT (IN / OUT panels)
|
||||||
|
========================================================================== */
|
||||||
|
.detailSplit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPanel {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPanelError {
|
||||||
|
border-color: var(--error-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPanelError .panelHeader {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border-bottom-color: var(--error-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepPanelLabel {
|
.arrowIn {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowOut {
|
||||||
|
color: var(--running);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowError {
|
||||||
|
color: var(--error);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelBody {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headers section */
|
||||||
|
.headersSection {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerKvRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerKvRow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerKey {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerValue {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body section */
|
||||||
|
.bodySection {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeBlock {
|
.count {
|
||||||
flex: 1;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error section */
|
|
||||||
.errorSection {
|
|
||||||
background: var(--error-bg);
|
|
||||||
border: 1px solid var(--error-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorBody {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorClass {
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
padding: 0 5px;
|
||||||
color: var(--error);
|
border-radius: 8px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error panel styles */
|
||||||
|
.errorBadgeRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorMessage {
|
.errorHttpBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessageBox {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-surface);
|
background: var(--error-bg);
|
||||||
border: 1px solid var(--error-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
white-space: pre-wrap;
|
border-radius: var(--radius-sm);
|
||||||
word-break: break-word;
|
border: 1px solid var(--error-border);
|
||||||
|
margin-bottom: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 8px;
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorHint {
|
.errorDetailGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
gap: 4px 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
}
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
.errorDetailLabel {
|
||||||
gap: 5px;
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorDetailValue {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import styles from './ExchangeDetail.module.css'
|
import styles from './ExchangeDetail.module.css'
|
||||||
|
|
||||||
@@ -10,18 +10,21 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
|||||||
// Composites
|
// Composites
|
||||||
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
||||||
import type { ProcessorStep } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
import type { ProcessorStep } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
||||||
|
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Collapsible } from '../../design-system/primitives/Collapsible/Collapsible'
|
|
||||||
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
||||||
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges } from '../../mocks/exchanges'
|
import { exchanges } from '../../mocks/exchanges'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||||
|
|
||||||
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
@@ -48,8 +51,7 @@ function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Exchange body mock generator ────────────────────────────────────────────
|
// ─── Exchange body mock generators ──────────────────────────────────────────
|
||||||
// For each processor step, generate a plausible exchange body snapshot
|
|
||||||
function generateExchangeSnapshot(
|
function generateExchangeSnapshot(
|
||||||
step: ProcessorStep,
|
step: ProcessorStep,
|
||||||
orderId: string,
|
orderId: string,
|
||||||
@@ -65,7 +67,7 @@ function generateExchangeSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'CamelCorrelationId': `cmr-${Math.random().toString(36).slice(2, 10)}`,
|
'CamelCorrelationId': `cmr-${orderId.toLowerCase().replace('op-', '')}-${stepIndex}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'CamelTimerName': step.name,
|
'CamelTimerName': step.name,
|
||||||
'CamelBreadcrumbId': `${orderId}-${stepIndex}`,
|
'CamelBreadcrumbId': `${orderId}-${stepIndex}`,
|
||||||
@@ -100,6 +102,61 @@ function generateExchangeSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateExchangeSnapshotOut(
|
||||||
|
step: ProcessorStep,
|
||||||
|
orderId: string,
|
||||||
|
customer: string,
|
||||||
|
stepIndex: number,
|
||||||
|
) {
|
||||||
|
const statusResult = step.status === 'fail' ? 'ERROR' : step.status === 'slow' ? 'SLOW_OK' : 'OK'
|
||||||
|
const baseBody = {
|
||||||
|
orderId,
|
||||||
|
customer,
|
||||||
|
status: statusResult,
|
||||||
|
processorStep: step.name,
|
||||||
|
stepIndex,
|
||||||
|
processed: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'CamelCorrelationId': `cmr-${orderId.toLowerCase().replace('op-', '')}-${stepIndex}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'CamelTimerName': step.name,
|
||||||
|
'CamelBreadcrumbId': `${orderId}-${stepIndex}`,
|
||||||
|
'CamelProcessedAt': new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'enrich') {
|
||||||
|
const source = step.name.replace('enrich(', '').replace(')', '')
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'enrichedBy': source,
|
||||||
|
'enrichmentComplete': 'true',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...baseBody,
|
||||||
|
enrichment: { source: step.name, addedFields: ['customerId', 'address', 'tier'], resolved: true },
|
||||||
|
}, null, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(baseBody, null, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map processor types to RouteNode types
|
||||||
|
function toRouteNodeType(procType: string): RouteNode['type'] {
|
||||||
|
switch (procType) {
|
||||||
|
case 'consumer': return 'from'
|
||||||
|
case 'transform': return 'process'
|
||||||
|
case 'enrich': return 'process'
|
||||||
|
default: return procType as RouteNode['type']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ExchangeDetail component ─────────────────────────────────────────────────
|
// ─── ExchangeDetail component ─────────────────────────────────────────────────
|
||||||
export function ExchangeDetail() {
|
export function ExchangeDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -107,6 +164,35 @@ export function ExchangeDetail() {
|
|||||||
|
|
||||||
const exchange = useMemo(() => exchanges.find((e) => e.id === id), [id])
|
const exchange = useMemo(() => exchanges.find((e) => e.id === id), [id])
|
||||||
|
|
||||||
|
// Find correlated exchanges, sorted by start time
|
||||||
|
const correlatedExchanges = useMemo(() => {
|
||||||
|
if (!exchange?.correlationGroup) return []
|
||||||
|
return exchanges
|
||||||
|
.filter((e) => e.correlationGroup === exchange.correlationGroup)
|
||||||
|
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
|
||||||
|
}, [exchange])
|
||||||
|
|
||||||
|
// Default selected processor: first failed, or 0
|
||||||
|
const defaultIndex = useMemo(() => {
|
||||||
|
if (!exchange) return 0
|
||||||
|
const failIdx = exchange.processors.findIndex((p) => p.status === 'fail')
|
||||||
|
return failIdx >= 0 ? failIdx : 0
|
||||||
|
}, [exchange])
|
||||||
|
|
||||||
|
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number>(defaultIndex)
|
||||||
|
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
|
||||||
|
|
||||||
|
// Build RouteFlow nodes from exchange processors
|
||||||
|
const routeNodes: RouteNode[] = useMemo(() => {
|
||||||
|
if (!exchange) return []
|
||||||
|
return exchange.processors.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
type: toRouteNodeType(p.type),
|
||||||
|
durationMs: p.durationMs,
|
||||||
|
status: p.status,
|
||||||
|
}))
|
||||||
|
}, [exchange])
|
||||||
|
|
||||||
// Not found state
|
// Not found state
|
||||||
if (!exchange) {
|
if (!exchange) {
|
||||||
return (
|
return (
|
||||||
@@ -122,7 +208,6 @@ export function ExchangeDetail() {
|
|||||||
{ label: id ?? 'Unknown' },
|
{ label: id ?? 'Unknown' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -134,6 +219,14 @@ export function ExchangeDetail() {
|
|||||||
|
|
||||||
const statusVariant = statusToVariant(exchange.status)
|
const statusVariant = statusToVariant(exchange.status)
|
||||||
const statusLabel = statusToLabel(exchange.status)
|
const statusLabel = statusToLabel(exchange.status)
|
||||||
|
const selectedProc = exchange.processors[selectedProcessorIndex]
|
||||||
|
const snapshotIn = selectedProc
|
||||||
|
? generateExchangeSnapshot(selectedProc, exchange.orderId, exchange.customer, selectedProcessorIndex)
|
||||||
|
: null
|
||||||
|
const snapshotOut = selectedProc
|
||||||
|
? generateExchangeSnapshotOut(selectedProc, exchange.orderId, exchange.customer, selectedProcessorIndex)
|
||||||
|
: null
|
||||||
|
const isSelectedFailed = selectedProc?.status === 'fail'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -145,18 +238,17 @@ export function ExchangeDetail() {
|
|||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: 'Applications', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
{ label: exchange.route, href: `/routes/${exchange.route}` },
|
{ label: exchange.route, href: `/apps/${ROUTE_TO_APP.get(exchange.route) ?? exchange.route}/${exchange.route}` },
|
||||||
{ label: exchange.id },
|
{ label: exchange.id },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
|
|
||||||
{/* Exchange header */}
|
{/* Exchange header card */}
|
||||||
<div className={styles.exchangeHeader}>
|
<div className={styles.exchangeHeader}>
|
||||||
<div className={styles.headerRow}>
|
<div className={styles.headerRow}>
|
||||||
<div className={styles.headerLeft}>
|
<div className={styles.headerLeft}>
|
||||||
@@ -167,10 +259,10 @@ export function ExchangeDetail() {
|
|||||||
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.exchangeRoute}>
|
<div className={styles.exchangeRoute}>
|
||||||
Route: <span className={styles.routeLink} onClick={() => navigate(`/routes/${exchange.route}`)}>{exchange.route}</span>
|
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${ROUTE_TO_APP.get(exchange.route) ?? exchange.route}/${exchange.route}`)}>{exchange.route}</span>
|
||||||
<span className={styles.headerDivider}>·</span>
|
<span className={styles.headerDivider}>·</span>
|
||||||
Order: <MonoText size="xs">{exchange.orderId}</MonoText>
|
Order: <MonoText size="xs">{exchange.orderId}</MonoText>
|
||||||
<span className={styles.headerDivider}>·</span>
|
<span className={styles.headerDivider}>·</span>
|
||||||
Customer: <MonoText size="xs">{exchange.customer}</MonoText>
|
Customer: <MonoText size="xs">{exchange.customer}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,98 +288,168 @@ export function ExchangeDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Processor timeline */}
|
|
||||||
<div className={styles.section}>
|
|
||||||
<div className={styles.sectionHeader}>
|
|
||||||
<span className={styles.sectionTitle}>Processor Timeline</span>
|
|
||||||
<span className={styles.sectionMeta}>Total: {formatDuration(exchange.durationMs)}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.timelineWrap}>
|
|
||||||
<ProcessorTimeline
|
|
||||||
processors={exchange.processors}
|
|
||||||
totalMs={exchange.durationMs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step-by-step inspector */}
|
|
||||||
<div className={styles.section}>
|
|
||||||
<div className={styles.sectionHeader}>
|
|
||||||
<span className={styles.sectionTitle}>Exchange Inspector</span>
|
|
||||||
<span className={styles.sectionMeta}>{exchange.processors.length} processor steps</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.inspectorSteps}>
|
|
||||||
{exchange.processors.map((proc, index) => {
|
|
||||||
const snapshot = generateExchangeSnapshot(proc, exchange.orderId, exchange.customer, index)
|
|
||||||
const stepStatusClass =
|
|
||||||
proc.status === 'fail'
|
|
||||||
? styles.stepFail
|
|
||||||
: proc.status === 'slow'
|
|
||||||
? styles.stepSlow
|
|
||||||
: styles.stepOk
|
|
||||||
|
|
||||||
|
{/* Correlation Chain */}
|
||||||
|
{correlatedExchanges.length > 1 && (
|
||||||
|
<div className={styles.correlationChain}>
|
||||||
|
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
||||||
|
{correlatedExchanges.map((ce) => {
|
||||||
|
const isCurrent = ce.id === exchange.id
|
||||||
|
const variant = statusToVariant(ce.status)
|
||||||
|
const statusCls =
|
||||||
|
variant === 'success' ? styles.chainNodeSuccess
|
||||||
|
: variant === 'error' ? styles.chainNodeError
|
||||||
|
: variant === 'running' ? styles.chainNodeRunning
|
||||||
|
: styles.chainNodeWarning
|
||||||
return (
|
return (
|
||||||
<Collapsible
|
<button
|
||||||
key={index}
|
key={ce.id}
|
||||||
title={
|
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
||||||
<div className={styles.stepTitle}>
|
onClick={() => {
|
||||||
<span className={`${styles.stepIndex} ${stepStatusClass}`}>{index + 1}</span>
|
if (!isCurrent) navigate(`/exchanges/${ce.id}`)
|
||||||
<span className={styles.stepName}>{proc.name}</span>
|
}}
|
||||||
<Badge
|
title={`${ce.id} — ${ce.route}`}
|
||||||
label={proc.status.toUpperCase()}
|
|
||||||
color={proc.status === 'fail' ? 'error' : proc.status === 'slow' ? 'warning' : 'success'}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
<span className={styles.stepDuration}>{formatDuration(proc.durationMs)}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
defaultOpen={proc.status === 'fail'}
|
|
||||||
className={styles.stepCollapsible}
|
|
||||||
>
|
>
|
||||||
<div className={styles.stepBody}>
|
<StatusDot variant={variant} />
|
||||||
<div className={styles.stepPanel}>
|
<span>{ce.route}</span>
|
||||||
<div className={styles.stepPanelLabel}>Exchange Headers</div>
|
</button>
|
||||||
<CodeBlock
|
|
||||||
content={JSON.stringify(snapshot.headers, null, 2)}
|
|
||||||
language="json"
|
|
||||||
copyable
|
|
||||||
className={styles.codeBlock}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.stepPanel}>
|
|
||||||
<div className={styles.stepPanelLabel}>Exchange Body</div>
|
|
||||||
<CodeBlock
|
|
||||||
content={snapshot.body}
|
|
||||||
language="json"
|
|
||||||
copyable
|
|
||||||
className={styles.codeBlock}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error block (if failed) */}
|
{/* Processor Timeline Section */}
|
||||||
{exchange.status === 'failed' && exchange.errorMessage && (
|
<div className={styles.timelineSection}>
|
||||||
<div className={styles.errorSection}>
|
<div className={styles.timelineHeader}>
|
||||||
<div className={styles.sectionHeader}>
|
<span className={styles.timelineTitle}>
|
||||||
<span className={styles.sectionTitle}>Error Details</span>
|
Processor Timeline
|
||||||
<Badge label="FAILED" color="error" />
|
<span className={styles.procCount}>{exchange.processors.length} processors</span>
|
||||||
</div>
|
</span>
|
||||||
<div className={styles.errorBody}>
|
<div className={styles.timelineToggle}>
|
||||||
<div className={styles.errorClass}>{exchange.errorClass}</div>
|
<button
|
||||||
<pre className={styles.errorMessage}>{exchange.errorMessage}</pre>
|
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
|
||||||
<div className={styles.errorHint}>
|
onClick={() => setTimelineView('gantt')}
|
||||||
Failed at processor: <MonoText size="xs">
|
>
|
||||||
{exchange.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'}
|
Timeline
|
||||||
</MonoText>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
|
||||||
|
onClick={() => setTimelineView('flow')}
|
||||||
|
>
|
||||||
|
Flow
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.timelineBody}>
|
||||||
|
{timelineView === 'gantt' ? (
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={exchange.processors}
|
||||||
|
totalMs={exchange.durationMs}
|
||||||
|
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
|
||||||
|
selectedIndex={selectedProcessorIndex}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RouteFlow
|
||||||
|
nodes={routeNodes}
|
||||||
|
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
|
||||||
|
selectedIndex={selectedProcessorIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processor Detail Panel (split IN / OUT) */}
|
||||||
|
{selectedProc && snapshotIn && snapshotOut && (
|
||||||
|
<div className={styles.detailSplit}>
|
||||||
|
{/* Message IN */}
|
||||||
|
<div className={styles.detailPanel}>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<span className={styles.panelTitle}>
|
||||||
|
<span className={styles.arrowIn}>→</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,21 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navSubLink {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px 2px 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSubLink:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -4,10 +4,88 @@ import { PrimitivesSection } from './sections/PrimitivesSection'
|
|||||||
import { CompositesSection } from './sections/CompositesSection'
|
import { CompositesSection } from './sections/CompositesSection'
|
||||||
import { LayoutSection } from './sections/LayoutSection'
|
import { LayoutSection } from './sections/LayoutSection'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_SECTIONS = [
|
||||||
{ label: 'Primitives', href: '#primitives' },
|
{
|
||||||
{ label: 'Composites', href: '#composites' },
|
label: 'Primitives',
|
||||||
{ label: 'Layout', href: '#layout' },
|
href: '#primitives',
|
||||||
|
components: [
|
||||||
|
{ label: 'Alert', href: '#alert' },
|
||||||
|
{ label: 'Avatar', href: '#avatar' },
|
||||||
|
{ label: 'Badge', href: '#badge' },
|
||||||
|
{ label: 'Button', href: '#button' },
|
||||||
|
{ label: 'ButtonGroup', href: '#buttongroup' },
|
||||||
|
{ label: 'Card', href: '#card' },
|
||||||
|
{ label: 'Checkbox', href: '#checkbox' },
|
||||||
|
{ label: 'CodeBlock', href: '#codeblock' },
|
||||||
|
{ label: 'Collapsible', href: '#collapsible' },
|
||||||
|
{ label: 'DateRangePicker', href: '#daterangepicker' },
|
||||||
|
{ label: 'DateTimePicker', href: '#datetimepicker' },
|
||||||
|
{ label: 'EmptyState', href: '#emptystate' },
|
||||||
|
{ label: 'FilterPill', href: '#filterpill' },
|
||||||
|
{ label: 'FormField', href: '#formfield' },
|
||||||
|
{ label: 'InfoCallout', href: '#infocallout' },
|
||||||
|
{ label: 'InlineEdit', href: '#inline-edit' },
|
||||||
|
{ label: 'Input', href: '#input' },
|
||||||
|
{ label: 'KeyboardHint', href: '#keyboardhint' },
|
||||||
|
{ label: 'Label', href: '#label' },
|
||||||
|
{ label: 'MonoText', href: '#monotext' },
|
||||||
|
{ label: 'Pagination', href: '#pagination' },
|
||||||
|
{ label: 'ProgressBar', href: '#progressbar' },
|
||||||
|
{ label: 'Radio', href: '#radio' },
|
||||||
|
{ label: 'SectionHeader', href: '#sectionheader' },
|
||||||
|
{ label: 'Select', href: '#select' },
|
||||||
|
{ label: 'Skeleton', href: '#skeleton' },
|
||||||
|
{ label: 'Sparkline', href: '#sparkline' },
|
||||||
|
{ label: 'Spinner', href: '#spinner' },
|
||||||
|
{ label: 'StatCard', href: '#statcard' },
|
||||||
|
{ label: 'StatusDot', href: '#statusdot' },
|
||||||
|
{ label: 'Tag', href: '#tag' },
|
||||||
|
{ label: 'Textarea', href: '#textarea' },
|
||||||
|
{ label: 'Toggle', href: '#toggle' },
|
||||||
|
{ label: 'Tooltip', href: '#tooltip' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Composites',
|
||||||
|
href: '#composites',
|
||||||
|
components: [
|
||||||
|
{ label: 'Accordion', href: '#accordion' },
|
||||||
|
{ label: 'AlertDialog', href: '#alertdialog' },
|
||||||
|
{ label: 'AreaChart', href: '#areachart' },
|
||||||
|
{ label: 'AvatarGroup', href: '#avatargroup' },
|
||||||
|
{ label: 'BarChart', href: '#barchart' },
|
||||||
|
{ label: 'Breadcrumb', href: '#breadcrumb' },
|
||||||
|
{ label: 'CommandPalette', href: '#commandpalette' },
|
||||||
|
{ label: 'ConfirmDialog', href: '#confirm-dialog' },
|
||||||
|
{ label: 'DataTable', href: '#datatable' },
|
||||||
|
{ label: 'DetailPanel', href: '#detailpanel' },
|
||||||
|
{ label: 'Dropdown', href: '#dropdown' },
|
||||||
|
{ label: 'EventFeed', href: '#eventfeed' },
|
||||||
|
{ label: 'FilterBar', href: '#filterbar' },
|
||||||
|
{ label: 'GroupCard', href: '#groupcard' },
|
||||||
|
{ label: 'LineChart', href: '#linechart' },
|
||||||
|
{ label: 'MenuItem', href: '#menuitem' },
|
||||||
|
{ label: 'Modal', href: '#modal' },
|
||||||
|
{ label: 'MultiSelect', href: '#multi-select' },
|
||||||
|
{ label: 'Popover', href: '#popover' },
|
||||||
|
{ label: 'ProcessorTimeline', href: '#processortimeline' },
|
||||||
|
{ label: 'RouteFlow', href: '#routeflow' },
|
||||||
|
{ label: 'SegmentedTabs', href: '#segmented-tabs' },
|
||||||
|
{ label: 'ShortcutsBar', href: '#shortcutsbar' },
|
||||||
|
{ label: 'Tabs', href: '#tabs' },
|
||||||
|
{ label: 'Toast', href: '#toast' },
|
||||||
|
{ label: 'TreeView', href: '#treeview' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Layout',
|
||||||
|
href: '#layout',
|
||||||
|
components: [
|
||||||
|
{ label: 'AppShell', href: '#appshell' },
|
||||||
|
{ label: 'Sidebar', href: '#sidebar' },
|
||||||
|
{ label: 'TopBar', href: '#topbar' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Inventory() {
|
export function Inventory() {
|
||||||
@@ -20,14 +98,16 @@ export function Inventory() {
|
|||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<nav className={styles.nav} aria-label="Component categories">
|
<nav className={styles.nav} aria-label="Component categories">
|
||||||
<div className={styles.navSection}>
|
{NAV_SECTIONS.map((section) => (
|
||||||
<span className={styles.navLabel}>Categories</span>
|
<div key={section.href} className={styles.navSection}>
|
||||||
{NAV_ITEMS.map((item) => (
|
<span className={styles.navLabel}>{section.label}</span>
|
||||||
<a key={item.href} href={item.href} className={styles.navLink}>
|
{section.components.map((component) => (
|
||||||
{item.label}
|
<a key={component.href} href={component.href} className={styles.navSubLink}>
|
||||||
|
{component.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
BarChart,
|
BarChart,
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
CommandPalette,
|
CommandPalette,
|
||||||
|
ConfirmDialog,
|
||||||
DataTable,
|
DataTable,
|
||||||
DetailPanel,
|
DetailPanel,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
@@ -17,8 +18,11 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Modal,
|
Modal,
|
||||||
|
MultiSelect,
|
||||||
Popover,
|
Popover,
|
||||||
ProcessorTimeline,
|
ProcessorTimeline,
|
||||||
|
RouteFlow,
|
||||||
|
SegmentedTabs,
|
||||||
ShortcutsBar,
|
ShortcutsBar,
|
||||||
Tabs,
|
Tabs,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
@@ -208,6 +212,13 @@ export function CompositesSection() {
|
|||||||
]
|
]
|
||||||
const [activeFilters, setActiveFilters] = useState([{ label: 'Live', value: 'live' }])
|
const [activeFilters, setActiveFilters] = useState([{ label: 'Live', value: 'live' }])
|
||||||
|
|
||||||
|
// ConfirmDialog
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
const [confirmDone, setConfirmDone] = useState(false)
|
||||||
|
|
||||||
|
// MultiSelect
|
||||||
|
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
|
||||||
|
|
||||||
// 15. Modal
|
// 15. Modal
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
|
||||||
@@ -218,6 +229,7 @@ export function CompositesSection() {
|
|||||||
{ label: 'Agents', value: 'agents', count: 6 },
|
{ label: 'Agents', value: 'agents', count: 6 },
|
||||||
]
|
]
|
||||||
const [activeTab, setActiveTab] = useState('overview')
|
const [activeTab, setActiveTab] = useState('overview')
|
||||||
|
const [segTab, setSegTab] = useState('account')
|
||||||
|
|
||||||
// 21. TreeView
|
// 21. TreeView
|
||||||
const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1')
|
const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1')
|
||||||
@@ -294,6 +306,21 @@ export function CompositesSection() {
|
|||||||
/>
|
/>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 2b. ConfirmDialog */}
|
||||||
|
<DemoCard id="confirm-dialog" title="ConfirmDialog" description="Type-to-confirm destructive action dialog. Built on Modal.">
|
||||||
|
<Button size="sm" variant="danger" onClick={() => { setConfirmOpen(true); setConfirmDone(false) }}>
|
||||||
|
Delete project
|
||||||
|
</Button>
|
||||||
|
{confirmDone && <span style={{ color: 'var(--success)', fontSize: 12, marginLeft: 8 }}>Deleted!</span>}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
onClose={() => setConfirmOpen(false)}
|
||||||
|
onConfirm={() => { setConfirmOpen(false); setConfirmDone(true) }}
|
||||||
|
message={'Delete project "my-project"? This cannot be undone.'}
|
||||||
|
confirmText="my-project"
|
||||||
|
/>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 3. AreaChart */}
|
{/* 3. AreaChart */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="areachart"
|
id="areachart"
|
||||||
@@ -511,6 +538,27 @@ export function CompositesSection() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 15b. MultiSelect */}
|
||||||
|
<DemoCard id="multi-select" title="MultiSelect" description="Dropdown with searchable checkbox list and Apply action.">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
|
||||||
|
<MultiSelect
|
||||||
|
options={[
|
||||||
|
{ value: 'admin', label: 'ADMIN' },
|
||||||
|
{ value: 'editor', label: 'EDITOR' },
|
||||||
|
{ value: 'viewer', label: 'VIEWER' },
|
||||||
|
{ value: 'operator', label: 'OPERATOR' },
|
||||||
|
{ value: 'auditor', label: 'AUDITOR' },
|
||||||
|
]}
|
||||||
|
value={multiValue}
|
||||||
|
onChange={setMultiValue}
|
||||||
|
placeholder="Add roles..."
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
Selected: {multiValue.join(', ') || 'none'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 16. Popover */}
|
{/* 16. Popover */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="popover"
|
id="popover"
|
||||||
@@ -560,6 +608,28 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 17b. RouteFlow */}
|
||||||
|
<DemoCard
|
||||||
|
id="routeflow"
|
||||||
|
title="RouteFlow"
|
||||||
|
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
<RouteFlow
|
||||||
|
nodes={[
|
||||||
|
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
|
||||||
|
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
|
||||||
|
{ name: 'sql:INSERT INTO orders', type: 'to', durationMs: 24, status: 'ok' },
|
||||||
|
{ name: 'header.priority == HIGH', type: 'choice', durationMs: 1, status: 'ok' },
|
||||||
|
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow', isBottleneck: true },
|
||||||
|
{ name: 'ResponseMapper', type: 'process', durationMs: 3, status: 'ok' },
|
||||||
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
|
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 18. ShortcutsBar */}
|
{/* 18. ShortcutsBar */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="shortcutsbar"
|
id="shortcutsbar"
|
||||||
@@ -591,6 +661,28 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 19b. SegmentedTabs */}
|
||||||
|
<DemoCard
|
||||||
|
id="segmented-tabs"
|
||||||
|
title="SegmentedTabs"
|
||||||
|
description="Pill-style segmented tab bar with elevated active state. Same API as Tabs."
|
||||||
|
>
|
||||||
|
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
|
||||||
|
<SegmentedTabs
|
||||||
|
tabs={[
|
||||||
|
{ label: 'Account', value: 'account' },
|
||||||
|
{ label: 'Password', value: 'password' },
|
||||||
|
{ label: 'Notifications', value: 'notifications', count: 3 },
|
||||||
|
]}
|
||||||
|
active={segTab}
|
||||||
|
onChange={setSegTab}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
Active tab: <strong>{segTab}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 20. Toast */}
|
{/* 20. Toast */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="toast"
|
id="toast"
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function LayoutSection() {
|
|||||||
>
|
>
|
||||||
<div className={styles.shellDiagram}>
|
<div className={styles.shellDiagram}>
|
||||||
<div className={styles.shellDiagramTop}>
|
<div className={styles.shellDiagramTop}>
|
||||||
TopBar — breadcrumb · search · env badge · shift · user avatar
|
TopBar — breadcrumb · search · filters · time range · env badge · user avatar
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shellDiagramBody}>
|
<div className={styles.shellDiagramBody}>
|
||||||
<div className={styles.shellDiagramSide}>
|
<div className={styles.shellDiagramSide}>
|
||||||
@@ -110,7 +110,7 @@ export function LayoutSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="topbar"
|
id="topbar"
|
||||||
title="TopBar"
|
title="TopBar"
|
||||||
description="Top navigation bar with breadcrumb, search trigger, environment badge, shift info, and user avatar."
|
description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar."
|
||||||
>
|
>
|
||||||
<div className={styles.topbarPreview}>
|
<div className={styles.topbarPreview}>
|
||||||
<TopBar
|
<TopBar
|
||||||
@@ -120,9 +120,8 @@ export function LayoutSection() {
|
|||||||
{ label: 'order-ingest' },
|
{ label: 'order-ingest' },
|
||||||
]}
|
]}
|
||||||
environment="production"
|
environment="production"
|
||||||
shift="Morning"
|
|
||||||
user={{ name: 'Hendrik' }}
|
user={{ name: 'Hendrik' }}
|
||||||
onSearchClick={() => undefined}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
Card,
|
Card,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
FilterPill,
|
FilterPill,
|
||||||
FormField,
|
FormField,
|
||||||
InfoCallout,
|
InfoCallout,
|
||||||
|
InlineEdit,
|
||||||
Input,
|
Input,
|
||||||
KeyboardHint,
|
KeyboardHint,
|
||||||
Label,
|
Label,
|
||||||
@@ -71,6 +73,9 @@ export function PrimitivesSection() {
|
|||||||
// Alert state
|
// Alert state
|
||||||
const [alertDismissed, setAlertDismissed] = useState(false)
|
const [alertDismissed, setAlertDismissed] = useState(false)
|
||||||
|
|
||||||
|
// ButtonGroup state
|
||||||
|
const [bgSelection, setBgSelection] = useState<Set<string>>(new Set(['warn']))
|
||||||
|
|
||||||
// Checkbox state
|
// Checkbox state
|
||||||
const [checked1, setChecked1] = useState(false)
|
const [checked1, setChecked1] = useState(false)
|
||||||
const [checked2, setChecked2] = useState(true)
|
const [checked2, setChecked2] = useState(true)
|
||||||
@@ -95,6 +100,9 @@ export function PrimitivesSection() {
|
|||||||
end: new Date('2026-03-18T23:59'),
|
end: new Date('2026-03-18T23:59'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// InlineEdit state
|
||||||
|
const [inlineValue, setInlineValue] = useState('Alice Johnson')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="primitives" className={styles.section}>
|
<section id="primitives" className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Primitives</h2>
|
<h2 className={styles.sectionTitle}>Primitives</h2>
|
||||||
@@ -174,6 +182,24 @@ export function PrimitivesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 4b. ButtonGroup */}
|
||||||
|
<DemoCard
|
||||||
|
id="buttongroup"
|
||||||
|
title="ButtonGroup"
|
||||||
|
description="Multi-select toggle group with optional colored dot indicators. Used for status filters."
|
||||||
|
>
|
||||||
|
<ButtonGroup
|
||||||
|
items={[
|
||||||
|
{ value: 'ok', label: 'OK', color: 'var(--success)' },
|
||||||
|
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
|
||||||
|
{ value: 'error', label: 'Error', color: 'var(--error)' },
|
||||||
|
{ value: 'running', label: 'Running', color: 'var(--running)' },
|
||||||
|
]}
|
||||||
|
value={bgSelection}
|
||||||
|
onChange={setBgSelection}
|
||||||
|
/>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 5. Card */}
|
{/* 5. Card */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="card"
|
id="card"
|
||||||
@@ -328,6 +354,15 @@ export function PrimitivesSection() {
|
|||||||
<Input icon="🔍" placeholder="With icon" />
|
<Input icon="🔍" placeholder="With icon" />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 15b. InlineEdit */}
|
||||||
|
<DemoCard id="inline-edit" title="InlineEdit" description="Click-to-edit text field. Enter saves, Escape/blur cancels.">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<InlineEdit value={inlineValue} onSave={setInlineValue} />
|
||||||
|
<InlineEdit value="" onSave={() => {}} placeholder="Click to add name..." />
|
||||||
|
<InlineEdit value="Read only" onSave={() => {}} disabled />
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 16. KeyboardHint */}
|
{/* 16. KeyboardHint */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="keyboardhint"
|
id="keyboardhint"
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
/* Scrollable content area */
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px 24px 40px;
|
|
||||||
min-width: 0;
|
|
||||||
background: var(--bg-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Date range picker bar */
|
|
||||||
.dateRangeBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 10px 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refreshIndicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refreshDot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--success);
|
|
||||||
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.refreshText {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* KPI strip */
|
|
||||||
.kpiStrip {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Route performance table */
|
|
||||||
.tableSection {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableTitle {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableRight {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableMeta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Route name in table */
|
|
||||||
.routeNameCell {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rate color classes */
|
|
||||||
.rateGood {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rateWarn {
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rateBad {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rateNeutral {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2x2 chart grid */
|
|
||||||
.chartGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartCard {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
padding: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartTitle {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import styles from './Metrics.module.css'
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
|
||||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|
||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
|
||||||
|
|
||||||
// Composites
|
|
||||||
import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart'
|
|
||||||
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
|
||||||
import { BarChart } from '../../design-system/composites/BarChart/BarChart'
|
|
||||||
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
|
||||||
import type { Column } from '../../design-system/composites/DataTable/types'
|
|
||||||
|
|
||||||
// Primitives
|
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
|
||||||
import { DateRangePicker } from '../../design-system/primitives/DateRangePicker/DateRangePicker'
|
|
||||||
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
|
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
|
||||||
|
|
||||||
// Mock data
|
|
||||||
import {
|
|
||||||
throughputSeries,
|
|
||||||
latencySeries,
|
|
||||||
errorCountSeries,
|
|
||||||
routeMetrics,
|
|
||||||
type RouteMetricRow,
|
|
||||||
} from '../../mocks/metrics'
|
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
||||||
|
|
||||||
// ─── Metrics KPI cards (5 cards per spec) ─────────────────────────────────────
|
|
||||||
const METRIC_KPIS = [
|
|
||||||
{
|
|
||||||
label: 'Throughput',
|
|
||||||
value: '47.2',
|
|
||||||
unit: 'msg/s',
|
|
||||||
trend: 'neutral' as const,
|
|
||||||
trendValue: '→',
|
|
||||||
detail: 'Capacity: 120 msg/s · 39%',
|
|
||||||
accent: 'running' as const,
|
|
||||||
sparkline: [44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Latency p99',
|
|
||||||
value: '287ms',
|
|
||||||
trend: 'up' as const,
|
|
||||||
trendValue: '+23ms',
|
|
||||||
detail: 'SLA: <300ms · CLOSE',
|
|
||||||
accent: 'warning' as const,
|
|
||||||
sparkline: [198, 212, 205, 218, 224, 231, 238, 245, 252, 261, 268, 275, 281, 287],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Error Rate',
|
|
||||||
value: '2.9%',
|
|
||||||
trend: 'up' as const,
|
|
||||||
trendValue: '+0.4%',
|
|
||||||
detail: '38 errors this shift',
|
|
||||||
accent: 'error' as const,
|
|
||||||
sparkline: [1.2, 1.8, 1.5, 2.1, 2.4, 2.2, 2.5, 2.6, 2.7, 2.8, 2.7, 2.9, 2.8, 2.9],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Success Rate',
|
|
||||||
value: '97.1%',
|
|
||||||
trend: 'down' as const,
|
|
||||||
trendValue: '-0.4%',
|
|
||||||
detail: '3,147 ok · 56 warn · 38 err',
|
|
||||||
accent: 'success' as const,
|
|
||||||
sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Active Routes',
|
|
||||||
value: 7,
|
|
||||||
trend: 'neutral' as const,
|
|
||||||
trendValue: '→',
|
|
||||||
detail: '4 healthy · 2 degraded · 1 stale',
|
|
||||||
accent: 'amber' as const,
|
|
||||||
sparkline: [7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ─── Route metric row with id field (required by DataTable) ──────────────────
|
|
||||||
type RouteMetricRowWithId = RouteMetricRow & { id: string }
|
|
||||||
|
|
||||||
const routeMetricsWithId: RouteMetricRowWithId[] = routeMetrics.map((r) => ({
|
|
||||||
...r,
|
|
||||||
id: r.routeId,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ─── Route performance table columns ──────────────────────────────────────────
|
|
||||||
const ROUTE_COLUMNS: Column<RouteMetricRowWithId>[] = [
|
|
||||||
{
|
|
||||||
key: 'routeName',
|
|
||||||
header: 'Route',
|
|
||||||
sortable: true,
|
|
||||||
render: (_, row) => (
|
|
||||||
<span className={styles.routeNameCell}>{row.routeName}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'exchangeCount',
|
|
||||||
header: 'Exchanges',
|
|
||||||
sortable: true,
|
|
||||||
render: (_, row) => (
|
|
||||||
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'successRate',
|
|
||||||
header: 'Success %',
|
|
||||||
sortable: true,
|
|
||||||
render: (_, row) => {
|
|
||||||
const cls = row.successRate >= 99 ? styles.rateGood : row.successRate >= 97 ? styles.rateWarn : styles.rateBad
|
|
||||||
return <MonoText size="sm" className={cls}>{row.successRate.toFixed(1)}%</MonoText>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'avgDurationMs',
|
|
||||||
header: 'Avg Duration',
|
|
||||||
sortable: true,
|
|
||||||
render: (_, row) => (
|
|
||||||
<MonoText size="sm">{row.avgDurationMs}ms</MonoText>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'p99DurationMs',
|
|
||||||
header: 'p99 Duration',
|
|
||||||
sortable: true,
|
|
||||||
render: (_, row) => {
|
|
||||||
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood
|
|
||||||
return <MonoText size="sm" className={cls}>{row.p99DurationMs}ms</MonoText>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'errorCount',
|
|
||||||
header: 'Errors',
|
|
||||||
sortable: true,
|
|
||||||
render: (_, row) => (
|
|
||||||
<MonoText size="sm" className={row.errorCount > 10 ? styles.rateBad : styles.rateNeutral}>
|
|
||||||
{row.errorCount}
|
|
||||||
</MonoText>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sparkline',
|
|
||||||
header: 'Trend',
|
|
||||||
render: (_, row) => (
|
|
||||||
<Sparkline data={row.sparkline} width={80} height={24} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ─── Build bar chart data from error series ────────────────────────────────────
|
|
||||||
function buildErrorBarSeries() {
|
|
||||||
// Take every 5th point and format x as time label
|
|
||||||
const sampleInterval = 5
|
|
||||||
return errorCountSeries.map((s) => ({
|
|
||||||
label: s.label,
|
|
||||||
data: s.data
|
|
||||||
.filter((_, i) => i % sampleInterval === 0)
|
|
||||||
.map((pt) => ({
|
|
||||||
x: pt.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
||||||
y: Math.round(pt.value),
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Build volume area chart (derived from throughput) ─────────────────────────
|
|
||||||
function buildVolumeSeries() {
|
|
||||||
return throughputSeries.map((s) => ({
|
|
||||||
label: s.label,
|
|
||||||
data: s.data.map((pt) => ({
|
|
||||||
x: pt.timestamp,
|
|
||||||
y: Math.round(pt.value * 60), // approx msg/min
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const ERROR_BAR_SERIES = buildErrorBarSeries()
|
|
||||||
const VOLUME_SERIES = buildVolumeSeries()
|
|
||||||
|
|
||||||
// Convert MetricSeries (from mocks) to ChartSeries format
|
|
||||||
function convertSeries(series: typeof throughputSeries) {
|
|
||||||
return series.map((s) => ({
|
|
||||||
label: s.label,
|
|
||||||
data: s.data.map((pt) => ({ x: pt.timestamp, y: pt.value })),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Metrics page ─────────────────────────────────────────────────────────────
|
|
||||||
export function Metrics() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [dateRange, setDateRange] = useState({
|
|
||||||
start: new Date('2026-03-18T06:00:00'),
|
|
||||||
end: new Date('2026-03-18T09:15:00'),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell
|
|
||||||
sidebar={
|
|
||||||
<Sidebar apps={SIDEBAR_APPS} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Top bar */}
|
|
||||||
<TopBar
|
|
||||||
breadcrumb={[
|
|
||||||
{ label: 'Applications', href: '/apps' },
|
|
||||||
{ label: 'Metrics' },
|
|
||||||
]}
|
|
||||||
environment="PRODUCTION"
|
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className={styles.content}>
|
|
||||||
|
|
||||||
{/* Date range picker bar */}
|
|
||||||
<div className={styles.dateRangeBar}>
|
|
||||||
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
|
||||||
<div className={styles.refreshIndicator}>
|
|
||||||
<span className={styles.refreshDot} />
|
|
||||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI stat cards (5) */}
|
|
||||||
<div className={styles.kpiStrip}>
|
|
||||||
{METRIC_KPIS.map((kpi, i) => (
|
|
||||||
<StatCard
|
|
||||||
key={i}
|
|
||||||
label={kpi.label}
|
|
||||||
value={kpi.value}
|
|
||||||
detail={kpi.detail}
|
|
||||||
trend={kpi.trend}
|
|
||||||
trendValue={kpi.trendValue}
|
|
||||||
accent={kpi.accent}
|
|
||||||
sparkline={kpi.sparkline}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Per-route performance table */}
|
|
||||||
<div className={styles.tableSection}>
|
|
||||||
<div className={styles.tableHeader}>
|
|
||||||
<span className={styles.tableTitle}>Per-Route Performance</span>
|
|
||||||
<div className={styles.tableRight}>
|
|
||||||
<span className={styles.tableMeta}>{routeMetrics.length} routes</span>
|
|
||||||
<Badge label="SHIFT" color="primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DataTable
|
|
||||||
columns={ROUTE_COLUMNS}
|
|
||||||
data={routeMetricsWithId}
|
|
||||||
sortable
|
|
||||||
onRowClick={(row) => navigate(`/routes/${row.routeId}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2x2 chart grid */}
|
|
||||||
<div className={styles.chartGrid}>
|
|
||||||
{/* Throughput area chart */}
|
|
||||||
<div className={styles.chartCard}>
|
|
||||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
|
||||||
<AreaChart
|
|
||||||
series={convertSeries(throughputSeries)}
|
|
||||||
yLabel="msg/s"
|
|
||||||
height={200}
|
|
||||||
width={500}
|
|
||||||
className={styles.chart}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Latency line chart with SLA threshold */}
|
|
||||||
<div className={styles.chartCard}>
|
|
||||||
<div className={styles.chartTitle}>Latency (ms)</div>
|
|
||||||
<LineChart
|
|
||||||
series={convertSeries(latencySeries)}
|
|
||||||
yLabel="ms"
|
|
||||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
|
||||||
height={200}
|
|
||||||
width={500}
|
|
||||||
className={styles.chart}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error bar chart */}
|
|
||||||
<div className={styles.chartCard}>
|
|
||||||
<div className={styles.chartTitle}>Errors by Route</div>
|
|
||||||
<BarChart
|
|
||||||
series={ERROR_BAR_SERIES}
|
|
||||||
stacked
|
|
||||||
height={200}
|
|
||||||
width={500}
|
|
||||||
className={styles.chart}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Volume area chart */}
|
|
||||||
<div className={styles.chartCard}>
|
|
||||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
|
||||||
<AreaChart
|
|
||||||
series={VOLUME_SERIES}
|
|
||||||
yLabel="msg/min"
|
|
||||||
height={200}
|
|
||||||
width={500}
|
|
||||||
className={styles.chart}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</AppShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -210,7 +210,7 @@ export function RouteDetail() {
|
|||||||
{ label: id ?? 'Unknown' },
|
{ label: id ?? 'Unknown' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -236,7 +236,7 @@ export function RouteDetail() {
|
|||||||
{ label: route.name },
|
{ label: route.name },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -328,6 +328,7 @@ export function RouteDetail() {
|
|||||||
columns={EXCHANGE_COLUMNS}
|
columns={EXCHANGE_COLUMNS}
|
||||||
data={routeExchanges}
|
data={routeExchanges}
|
||||||
sortable
|
sortable
|
||||||
|
flush
|
||||||
rowAccent={(row) => {
|
rowAccent={(row) => {
|
||||||
if (row.status === 'failed') return 'error'
|
if (row.status === 'failed') return 'error'
|
||||||
if (row.status === 'warning') return 'warning'
|
if (row.status === 'warning') return 'warning'
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user