From bf289aa1b119715c1f28476c97fd191566543c31 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:27:53 +0200 Subject: [PATCH 01/19] docs: add runtime compact view design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-16-runtime-compact-view-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-16-runtime-compact-view-design.md diff --git a/docs/superpowers/specs/2026-04-16-runtime-compact-view-design.md b/docs/superpowers/specs/2026-04-16-runtime-compact-view-design.md new file mode 100644 index 00000000..5c616c79 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-runtime-compact-view-design.md @@ -0,0 +1,91 @@ +# Runtime Dashboard — Compact View + +## Summary + +Add a compact/collapsed view mode to the runtime dashboard (AgentHealth page). In compact mode, each application is rendered as a small status card showing app name, live agent count, and last heartbeat, with health-state coloring (tinted background + tinted border + thick left accent border). Individual cards can be expanded inline to reveal the full agent instance table. A global toggle switches between compact and expanded views. + +## Decisions + +| Question | Decision | +|----------|----------| +| Default view | Compact — all apps start collapsed | +| Expand behavior | Inline — card expands in place, pushes others down | +| Grid layout | Auto-fill responsive (`repeat(auto-fill, minmax(220px, 1fr))`) | +| Toggle location | Inside stat strip area (icon toggle, no new toolbar row) | +| Health coloring | Same logic as existing `appHealth()` — worst agent state wins | +| Card styling | Tinted background (8% health color) + tinted border (25%) + 3px left accent border | + +## View Mode State + +- `viewMode: 'compact' | 'expanded'` — persisted to `localStorage` key `cameleer:runtime:viewMode`, default `'compact'`. +- `expandedApps: Set` — tracks individually expanded app IDs. Ephemeral (not persisted). +- When `viewMode === 'compact'`: apps in `expandedApps` show the full GroupCard table. All others show compact cards. +- When `viewMode === 'expanded'`: all apps show the full GroupCard table (current behavior). `expandedApps` is ignored. +- Collapse all / expand all: sets `viewMode` and clears `expandedApps`. + +## View Toggle + +- Two-button icon toggle rendered in the `.statStrip` area after the last StatCard. +- Uses `Button` from `@cameleer/design-system` with `variant="ghost"`. Active button styled with `var(--running)` background. +- Icons: `LayoutGrid` (compact) / `List` (expanded) from `lucide-react`. +- `.statStrip` grid changes from `repeat(5, 1fr)` to `repeat(5, 1fr) auto` to accommodate the toggle. + +## Compact Card (CompactAppCard) + +Inline component in `AgentHealth.tsx` (page-specific, not reusable). + +**Props:** `group: AppGroup`, `onExpand: () => void`. + +**Layout:** +- Container: `var(--bg-raised)` with health-color tint via CSS custom property `--card-accent`. + - `background: color-mix(in srgb, var(--card-accent) 8%, var(--bg-raised))` + - `border: 1px solid color-mix(in srgb, var(--card-accent) 25%, transparent)` + - `border-left: 3px solid var(--card-accent)` + - `border-radius: var(--radius-md)` + - Cursor pointer, entire card clickable. +- **Row 1:** App name (font-weight 700, `var(--text-primary)`) left. `ChevronRight` icon (`var(--text-muted)`) right. +- **Row 2:** `"{liveCount}/{total} live"` in health color (font-weight 600) left. Last heartbeat right — computed as `timeAgo(max(instances.map(i => i.lastHeartbeat)))` (most recent across all instances in the group). Color: `var(--text-muted)` when healthy, health color when stale/dead. +- **Hover:** tint increases to 12%. + +**Health color mapping** (reuses existing `appHealth()` function): +- `.compactCardSuccess` → `--card-accent: var(--success)` +- `.compactCardWarning` → `--card-accent: var(--warning)` +- `.compactCardError` → `--card-accent: var(--error)` + +## Expanded Card in Compact Mode + +- Renders existing `GroupCard` + `DataTable` unchanged. +- GroupCard header gets a `ChevronDown` icon button to collapse back. +- Expanded card gets `grid-column: span 2` in the compact grid for adequate table width (roughly matches current half-page GroupCard width). +- **Animation (single card toggle):** CSS transition on wrapper div with `overflow: hidden`. Expand: compact fades out (opacity 0, ~100ms), expanded fades in + grows (opacity 1, max-height transition, ~200ms ease-out). Collapse: reverse. Collapse-all / expand-all toggle is instant (no animation). + +## Grid Layout + +- `viewMode === 'expanded'`: existing `.groupGrid` (2-column `1fr 1fr`, unchanged). +- `viewMode === 'compact'`: new `.compactGrid` class: + ```css + .compactGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 20px; + } + ``` + +## Files Changed + +| File | Change | +|------|--------| +| `ui/src/pages/AgentHealth/AgentHealth.tsx` | `viewMode`/`expandedApps` state, localStorage init, `CompactAppCard` component, view toggle in stat strip, conditional grid rendering | +| `ui/src/pages/AgentHealth/AgentHealth.module.css` | `.compactGrid`, `.compactCard`, `.compactCardSuccess/Warning/Error`, `.compactCardHeader`, `.compactCardMeta`, `.viewToggle`, `.viewToggleActive`, animation classes, `.statStrip` column adjustment | +| `.claude/rules/ui.md` | Add compact view mention under Runtime section | + +No new files. No new design system components. No API changes. Feature is entirely contained in the AgentHealth page and its CSS module. + +## Style Guide Compliance + +- All colors via `@cameleer/design-system` CSS variables (`var(--success)`, `var(--warning)`, `var(--error)`, `var(--bg-raised)`, `var(--text-primary)`, `var(--text-muted)`, `var(--running)`, `var(--radius-md)`, `var(--border-subtle)`). +- No hardcoded hex values. +- Shared CSS modules imported where applicable. +- Design system components (`Button`, `GroupCard`, `DataTable`, `StatusDot`, `Badge`, `MonoText`) used throughout. +- `lucide-react` for icons (consistent with rest of UI). From 23d24487d16bdde9140cdb820f4f1050a0a1d61b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:32:14 +0200 Subject: [PATCH 02/19] docs: add runtime compact view implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-16-runtime-compact-view.md | 764 ++++++++++++++++++ 1 file changed, 764 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-16-runtime-compact-view.md diff --git a/docs/superpowers/plans/2026-04-16-runtime-compact-view.md b/docs/superpowers/plans/2026-04-16-runtime-compact-view.md new file mode 100644 index 00000000..4df07cb9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-runtime-compact-view.md @@ -0,0 +1,764 @@ +# Runtime Compact View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a compact/collapsed view mode to the AgentHealth runtime dashboard where each app is a small health-tinted card, expandable inline to the full agent table. + +**Architecture:** Add `viewMode` and `expandedApps` state to `AgentHealth`. A `CompactAppCard` inline component renders the collapsed card. The existing `GroupCard`+`DataTable` renders expanded apps. A view toggle in the stat strip switches modes. All CSS uses design system variables with `color-mix()` for health tinting. + +**Tech Stack:** React, CSS Modules, lucide-react, @cameleer/design-system components, localStorage + +--- + +### Task 1: Add compact card CSS classes + +**Files:** +- Modify: `ui/src/pages/AgentHealth/AgentHealth.module.css` + +- [ ] **Step 1: Add compact grid class** + +Append to `AgentHealth.module.css` after the `.groupGridSingle` block (line 94): + +```css +/* Compact view grid */ +.compactGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} +``` + +- [ ] **Step 2: Add compact card base and health variant classes** + +Append to `AgentHealth.module.css`: + +```css +/* Compact app card */ +.compactCard { + background: color-mix(in srgb, var(--card-accent) 8%, var(--bg-raised)); + border: 1px solid color-mix(in srgb, var(--card-accent) 25%, transparent); + border-left: 3px solid var(--card-accent); + border-radius: var(--radius-md); + padding: 10px 12px; + cursor: pointer; + transition: background 150ms ease; +} + +.compactCard:hover { + background: color-mix(in srgb, var(--card-accent) 12%, var(--bg-raised)); +} + +.compactCardSuccess { --card-accent: var(--success); } +.compactCardWarning { --card-accent: var(--warning); } +.compactCardError { --card-accent: var(--error); } + +.compactCardHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.compactCardName { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compactCardChevron { + color: var(--text-muted); + flex-shrink: 0; +} + +.compactCardMeta { + display: flex; + justify-content: space-between; + font-size: 11px; +} + +.compactCardLive { + font-weight: 600; + color: var(--card-accent); +} + +.compactCardHeartbeat { + color: var(--text-muted); +} + +.compactCardHeartbeatWarn { + color: var(--card-accent); +} +``` + +- [ ] **Step 3: Add expanded-in-compact-grid span class** + +Append to `AgentHealth.module.css`: + +```css +/* Expanded card inside compact grid */ +.compactGridExpanded { + grid-column: span 2; +} +``` + +- [ ] **Step 4: Add animation classes** + +Append to `AgentHealth.module.css`: + +```css +/* Expand/collapse animation wrapper */ +.expandWrapper { + overflow: hidden; + transition: max-height 200ms ease, opacity 150ms ease; +} + +.expandWrapperCollapsed { + max-height: 0; + opacity: 0; +} + +.expandWrapperExpanded { + max-height: 1000px; + opacity: 1; +} +``` + +- [ ] **Step 5: Add view toggle classes and update stat strip** + +Append to `AgentHealth.module.css`: + +```css +/* View mode toggle */ +.viewToggle { + display: flex; + align-items: center; + gap: 4px; +} + +.viewToggleBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.viewToggleBtn:hover { + color: var(--text-primary); +} + +.viewToggleBtnActive { + background: var(--running); + border-color: var(--running); + color: #fff; +} +``` + +- [ ] **Step 6: Update stat strip grid to add auto column** + +In `AgentHealth.module.css`, change the `.statStrip` rule from: + +```css +.statStrip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + margin-bottom: 16px; +} +``` + +to: + +```css +.statStrip { + display: grid; + grid-template-columns: repeat(5, 1fr) auto; + gap: 10px; + margin-bottom: 16px; +} +``` + +- [ ] **Step 7: Add collapse button class for expanded cards** + +Append to `AgentHealth.module.css`: + +```css +/* Collapse button in expanded GroupCard header */ +.collapseBtn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 2px 4px; + border-radius: var(--radius-sm); + line-height: 1; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; +} + +.collapseBtn:hover { + color: var(--text-primary); +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add ui/src/pages/AgentHealth/AgentHealth.module.css +git commit -m "style: add compact view CSS classes for runtime dashboard" +``` + +--- + +### Task 2: Add view mode state and toggle to AgentHealth + +**Files:** +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx:1-3` (imports) +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx:100-120` (state) +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx:339-402` (stat strip JSX) + +- [ ] **Step 1: Add lucide-react imports** + +In `AgentHealth.tsx`, change line 3 from: + +```tsx +import { ExternalLink, RefreshCw, Pencil } from 'lucide-react'; +``` + +to: + +```tsx +import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown } from 'lucide-react'; +``` + +- [ ] **Step 2: Add view mode state** + +In `AgentHealth.tsx`, after the `const [confirmDismissOpen, setConfirmDismissOpen] = useState(false);` line (line 116), add: + +```tsx + const [viewMode, setViewMode] = useState<'compact' | 'expanded'>(() => { + const saved = localStorage.getItem('cameleer:runtime:viewMode'); + return saved === 'expanded' ? 'expanded' : 'compact'; + }); + const [expandedApps, setExpandedApps] = useState>(new Set()); + + const toggleViewMode = useCallback((mode: 'compact' | 'expanded') => { + setViewMode(mode); + setExpandedApps(new Set()); + localStorage.setItem('cameleer:runtime:viewMode', mode); + }, []); + + const toggleAppExpanded = useCallback((appId: string) => { + setExpandedApps((prev) => { + const next = new Set(prev); + if (next.has(appId)) next.delete(appId); + else next.add(appId); + return next; + }); + }, []); +``` + +- [ ] **Step 3: Add view toggle to stat strip** + +In `AgentHealth.tsx`, inside the stat strip `
`, after the last `` (the "Dead" one ending around line 401), add: + +```tsx +
+ + +
+``` + +- [ ] **Step 4: Verify the dev server compiles without errors** + +Run: `cd ui && npx vite build --mode development 2>&1 | tail -5` +Expected: build succeeds (the toggle renders but has no visual effect on the grid yet) + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/pages/AgentHealth/AgentHealth.tsx +git commit -m "feat: add view mode state and toggle to runtime dashboard" +``` + +--- + +### Task 3: Add CompactAppCard component and compact grid rendering + +**Files:** +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx` + +- [ ] **Step 1: Add lastHeartbeat helper to helpers section** + +In `AgentHealth.tsx`, after the `appHealth()` function (after line 81), add: + +```tsx +function latestHeartbeat(group: AppGroup): string | undefined { + let latest: string | undefined; + for (const inst of group.instances) { + if (inst.lastHeartbeat && (!latest || inst.lastHeartbeat > latest)) { + latest = inst.lastHeartbeat; + } + } + return latest; +} +``` + +- [ ] **Step 2: Add CompactAppCard component** + +In `AgentHealth.tsx`, after the `LOG_SOURCE_ITEMS` constant (after line 97), add: + +```tsx +function CompactAppCard({ group, onExpand }: { group: AppGroup; onExpand: () => void }) { + const health = appHealth(group); + const heartbeat = latestHeartbeat(group); + const isHealthy = health === 'success'; + + const variantClass = + health === 'success' ? styles.compactCardSuccess + : health === 'warning' ? styles.compactCardWarning + : styles.compactCardError; + + return ( +
{ if (e.key === 'Enter' || e.key === ' ') onExpand(); }} + > +
+ {group.appId} + +
+
+ + {group.liveCount}/{group.instances.length} live + + + {heartbeat ? timeAgo(heartbeat) : '\u2014'} + +
+
+ ); +} +``` + +- [ ] **Step 3: Replace the group cards grid with conditional rendering** + +In `AgentHealth.tsx`, replace the entire group cards grid block (lines 515-569): + +```tsx + {/* Group cards grid */} +
+ {groups.map((group) => ( + + ))} +
+``` + +with: + +```tsx + {/* Group cards grid */} + {viewMode === 'expanded' || isFullWidth ? ( +
+ {groups.map((group) => ( + + } + meta={ +
+ {group.totalTps.toFixed(1)} msg/s + {group.totalActiveRoutes}/{group.totalRoutes} routes + + + +
+ } + footer={ + group.deadCount > 0 ? ( +
+ + + Single point of failure —{' '} + {group.deadCount === group.instances.length + ? 'no redundancy' + : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} + +
+ ) : undefined + } + > + + columns={instanceColumns as Column[]} + data={group.instances.map(i => ({ ...i, id: i.instanceId }))} + onRowClick={handleInstanceClick} + pageSize={50} + flush + /> +
+ ))} +
+ ) : ( +
+ {groups.map((group) => + expandedApps.has(group.appId) ? ( +
+ + + +
+ } + meta={ +
+ {group.totalTps.toFixed(1)} msg/s + {group.totalActiveRoutes}/{group.totalRoutes} routes + + + +
+ } + footer={ + group.deadCount > 0 ? ( +
+ + + Single point of failure —{' '} + {group.deadCount === group.instances.length + ? 'no redundancy' + : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} + +
+ ) : undefined + } + > + + columns={instanceColumns as Column[]} + data={group.instances.map(i => ({ ...i, id: i.instanceId }))} + onRowClick={handleInstanceClick} + pageSize={50} + flush + /> + +
+ ) : ( + toggleAppExpanded(group.appId)} + /> + ), + )} +
+ )} +``` + +- [ ] **Step 4: Verify the dev server compiles without errors** + +Run: `cd ui && npx vite build --mode development 2>&1 | tail -5` +Expected: build succeeds + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/pages/AgentHealth/AgentHealth.tsx +git commit -m "feat: add compact app cards with inline expand to runtime dashboard" +``` + +--- + +### Task 4: Add expand/collapse animation for single card toggle + +**Files:** +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx` + +- [ ] **Step 1: Add useRef and useEffect to imports** + +In `AgentHealth.tsx`, change line 1 from: + +```tsx +import { useState, useMemo, useCallback } from 'react'; +``` + +to: + +```tsx +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +``` + +- [ ] **Step 2: Add animating state to track which apps are transitioning** + +In `AgentHealth.tsx`, after the `toggleAppExpanded` callback, add: + +```tsx + const [animatingApps, setAnimatingApps] = useState>(new Map()); + const animationTimers = useRef>>(new Map()); + + const animateToggle = useCallback((appIdToToggle: string) => { + // Clear any existing timer for this app + const existing = animationTimers.current.get(appIdToToggle); + if (existing) clearTimeout(existing); + + const isCurrentlyExpanded = expandedApps.has(appIdToToggle); + + if (isCurrentlyExpanded) { + // Collapsing: start animation, then remove from expandedApps after transition + setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'collapsing')); + const timer = setTimeout(() => { + setExpandedApps((prev) => { + const next = new Set(prev); + next.delete(appIdToToggle); + return next; + }); + setAnimatingApps((prev) => { + const next = new Map(prev); + next.delete(appIdToToggle); + return next; + }); + animationTimers.current.delete(appIdToToggle); + }, 200); + animationTimers.current.set(appIdToToggle, timer); + } else { + // Expanding: add to expandedApps immediately, animate in + setExpandedApps((prev) => new Set(prev).add(appIdToToggle)); + setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'expanding')); + // Use requestAnimationFrame to ensure the collapsed state renders first + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setAnimatingApps((prev) => { + const next = new Map(prev); + next.delete(appIdToToggle); + return next; + }); + }); + }); + } + }, [expandedApps]); + + // Cleanup timers on unmount + useEffect(() => { + return () => { + animationTimers.current.forEach((timer) => clearTimeout(timer)); + }; + }, []); +``` + +- [ ] **Step 3: Update compact grid to use animateToggle and animation classes** + +In the compact grid section of the JSX, replace the expanded card wrapper and the `CompactAppCard`'s `onExpand` to use `animateToggle` instead of `toggleAppExpanded`. + +For the expanded card inside the compact grid, wrap the `GroupCard` in the animation wrapper: + +Change the expanded card branch from: + +```tsx + expandedApps.has(group.appId) ? ( +
+ + animateToggle(group.appId)} +``` + +Update the `CompactAppCard`'s `onExpand`: + +```tsx + animateToggle(group.appId)} + /> +``` + +- [ ] **Step 4: Verify build** + +Run: `cd ui && npx vite build --mode development 2>&1 | tail -5` +Expected: build succeeds + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/pages/AgentHealth/AgentHealth.tsx +git commit -m "feat: add expand/collapse animation for compact card toggle" +``` + +--- + +### Task 5: Visual testing and polish + +**Files:** +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx` (if adjustments needed) +- Modify: `ui/src/pages/AgentHealth/AgentHealth.module.css` (if adjustments needed) + +- [ ] **Step 1: Start the dev server** + +Run: `cd ui && npm run dev` + +- [ ] **Step 2: Test compact view default** + +Open the runtime dashboard in a browser. Verify: +- All apps render as compact cards (tinted background, left border, health colors) +- The view toggle shows the grid icon as active +- Cards display app name, live count (x/y), last heartbeat + +- [ ] **Step 3: Test single card expand** + +Click a compact card. Verify: +- It expands inline with animation (fade in + grow) +- The expanded card shows the full agent table (DataTable) +- It spans 2 columns in the grid +- A collapse button (ChevronDown) appears in the header + +- [ ] **Step 4: Test single card collapse** + +Click the collapse button on an expanded card. Verify: +- It animates back to compact (fade out + shrink) +- Returns to compact card form + +- [ ] **Step 5: Test expand all** + +Click the list icon in the view toggle. Verify: +- All cards instantly switch to expanded GroupCards (no animation) +- The list icon is now active + +- [ ] **Step 6: Test collapse all** + +Click the grid icon in the view toggle. Verify: +- All cards instantly switch to compact cards (no animation) +- Per-app overrides are cleared + +- [ ] **Step 7: Test localStorage persistence** + +Verify: +- Switch to expanded mode, refresh the page — stays in expanded mode +- Switch to compact mode, refresh the page — stays in compact mode + +- [ ] **Step 8: Test app-scoped view (single app route)** + +Navigate to a specific app route (e.g., `/runtime/some-app`). Verify: +- The view toggle still shows but the app-scoped layout uses `groupGridSingle` (full width) +- Compact mode is not used when a specific app is selected (`isFullWidth`) + +- [ ] **Step 9: Fix any visual issues found** + +Adjust padding, font sizes, colors, or grid breakpoints as needed. All colors must use CSS variables. + +- [ ] **Step 10: Commit** + +```bash +git add ui/src/pages/AgentHealth/AgentHealth.tsx ui/src/pages/AgentHealth/AgentHealth.module.css +git commit -m "fix: polish compact view styling after visual testing" +``` + +--- + +### Task 6: Update rules file + +**Files:** +- Modify: `.claude/rules/ui.md` + +- [ ] **Step 1: Add compact view to Runtime description** + +In `.claude/rules/ui.md`, change: + +``` +- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`) +``` + +to: + +``` +- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`). AgentHealth supports compact view (dense health-tinted cards) and expanded view (full GroupCard+DataTable per app). View mode persisted to localStorage. +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/rules/ui.md +git commit -m "docs: add compact view to runtime section of ui rules" +``` From 5c94881608a30cd8ef6b96e287b4b8080ba66f98 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:35:33 +0200 Subject: [PATCH 03/19] style: add compact view CSS classes for runtime dashboard Co-Authored-By: Claude Sonnet 4.6 --- .../pages/AgentHealth/AgentHealth.module.css | 140 +++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 603d6794..5f0a9283 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -13,7 +13,7 @@ /* Stat strip */ .statStrip { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(5, 1fr) auto; gap: 10px; margin-bottom: 16px; } @@ -93,6 +93,14 @@ margin-bottom: 20px; } +/* Compact view grid */ +.compactGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + /* Group meta row */ .groupMeta { display: flex; @@ -250,3 +258,133 @@ border-bottom: 1px solid var(--border-subtle); } +/* Compact app card */ +.compactCard { + background: color-mix(in srgb, var(--card-accent) 8%, var(--bg-raised)); + border: 1px solid color-mix(in srgb, var(--card-accent) 25%, transparent); + border-left: 3px solid var(--card-accent); + border-radius: var(--radius-md); + padding: 10px 12px; + cursor: pointer; + transition: background 150ms ease; +} + +.compactCard:hover { + background: color-mix(in srgb, var(--card-accent) 12%, var(--bg-raised)); +} + +.compactCardSuccess { --card-accent: var(--success); } +.compactCardWarning { --card-accent: var(--warning); } +.compactCardError { --card-accent: var(--error); } + +.compactCardHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.compactCardName { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compactCardChevron { + color: var(--text-muted); + flex-shrink: 0; +} + +.compactCardMeta { + display: flex; + justify-content: space-between; + font-size: 11px; +} + +.compactCardLive { + font-weight: 600; + color: var(--card-accent); +} + +.compactCardHeartbeat { + color: var(--text-muted); +} + +.compactCardHeartbeatWarn { + color: var(--card-accent); +} + +/* Expanded card inside compact grid */ +.compactGridExpanded { + grid-column: span 2; +} + +/* Expand/collapse animation wrapper */ +.expandWrapper { + overflow: hidden; + transition: max-height 200ms ease, opacity 150ms ease; +} + +.expandWrapperCollapsed { + max-height: 0; + opacity: 0; +} + +.expandWrapperExpanded { + max-height: 1000px; + opacity: 1; +} + +/* View mode toggle */ +.viewToggle { + display: flex; + align-items: center; + gap: 4px; +} + +.viewToggleBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.viewToggleBtn:hover { + color: var(--text-primary); +} + +.viewToggleBtnActive { + background: var(--running); + border-color: var(--running); + color: #fff; +} + +/* Collapse button in expanded GroupCard header */ +.collapseBtn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 2px 4px; + border-radius: var(--radius-sm); + line-height: 1; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; +} + +.collapseBtn:hover { + color: var(--text-primary); +} + From d0c2fd1ac3176b8364ef48935684347d07019d46 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:37:16 +0200 Subject: [PATCH 04/19] feat: add view mode state and toggle to runtime dashboard Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/AgentHealth/AgentHealth.tsx | 39 +++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 2e25fbd2..c7bcfcbe 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { ExternalLink, RefreshCw, Pencil } from 'lucide-react'; +import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown } from 'lucide-react'; import { StatCard, StatusDot, Badge, MonoText, GroupCard, DataTable, EventFeed, @@ -114,6 +114,27 @@ export default function AgentHealth() { const catalogEntry = catalogApps?.find((a) => a.slug === appId); const [confirmDismissOpen, setConfirmDismissOpen] = useState(false); + const [viewMode, setViewMode] = useState<'compact' | 'expanded'>(() => { + const saved = localStorage.getItem('cameleer:runtime:viewMode'); + return saved === 'expanded' ? 'expanded' : 'compact'; + }); + const [expandedApps, setExpandedApps] = useState>(new Set()); + + const toggleViewMode = useCallback((mode: 'compact' | 'expanded') => { + setViewMode(mode); + setExpandedApps(new Set()); + localStorage.setItem('cameleer:runtime:viewMode', mode); + }, []); + + const toggleAppExpanded = useCallback((appId: string) => { + setExpandedApps((prev) => { + const next = new Set(prev); + if (next.has(appId)) next.delete(appId); + else next.add(appId); + return next; + }); + }, []); + const [configEditing, setConfigEditing] = useState(false); const [configDraft, setConfigDraft] = useState>({}); @@ -399,6 +420,22 @@ export default function AgentHealth() { accent={deadCount > 0 ? 'error' : 'success'} detail={deadCount > 0 ? 'requires attention' : 'all healthy'} /> +
+ + +
{/* Application config bar */} From 5229e08b2716b428a42708fa11a0700d6fd88227 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:37:58 +0200 Subject: [PATCH 05/19] feat: add compact app cards with inline expand to runtime dashboard Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/AgentHealth/AgentHealth.tsx | 221 +++++++++++++++++------ 1 file changed, 170 insertions(+), 51 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index c7bcfcbe..0087b644 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -80,6 +80,16 @@ function appHealth(group: AppGroup): 'success' | 'warning' | 'error' { return 'success'; } +function latestHeartbeat(group: AppGroup): string | undefined { + let latest: string | undefined; + for (const inst of group.instances) { + if (inst.lastHeartbeat && (!latest || inst.lastHeartbeat > latest)) { + latest = inst.lastHeartbeat; + } + } + return latest; +} + // ── Detail sub-components ──────────────────────────────────────────────────── const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [ @@ -96,6 +106,40 @@ const LOG_SOURCE_ITEMS: ButtonGroupItem[] = [ { value: 'container', label: 'Container' }, ]; +function CompactAppCard({ group, onExpand }: { group: AppGroup; onExpand: () => void }) { + const health = appHealth(group); + const heartbeat = latestHeartbeat(group); + const isHealthy = health === 'success'; + + const variantClass = + health === 'success' ? styles.compactCardSuccess + : health === 'warning' ? styles.compactCardWarning + : styles.compactCardError; + + return ( +
{ if (e.key === 'Enter' || e.key === ' ') onExpand(); }} + > +
+ {group.appId} + +
+
+ + {group.liveCount}/{group.instances.length} live + + + {heartbeat ? timeAgo(heartbeat) : '\u2014'} + +
+
+ ); +} + // ── AgentHealth page ───────────────────────────────────────────────────────── export default function AgentHealth() { @@ -550,60 +594,135 @@ export default function AgentHealth() { {/* Group cards grid */} -
- {groups.map((group) => ( - - } - meta={ -
- {group.totalTps.toFixed(1)} msg/s - {group.totalActiveRoutes}/{group.totalRoutes} routes - - - -
- } - footer={ - group.deadCount > 0 ? ( -
- + {viewMode === 'expanded' || isFullWidth ? ( +
+ {groups.map((group) => ( + + } + meta={ +
+ {group.totalTps.toFixed(1)} msg/s + {group.totalActiveRoutes}/{group.totalRoutes} routes - Single point of failure —{' '} - {group.deadCount === group.instances.length - ? 'no redundancy' - : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} +
- ) : undefined - } - > - - columns={instanceColumns as Column[]} - data={group.instances.map(i => ({ ...i, id: i.instanceId }))} - onRowClick={handleInstanceClick} - pageSize={50} - flush - /> -
- ))} -
+ } + footer={ + group.deadCount > 0 ? ( +
+ + + Single point of failure —{' '} + {group.deadCount === group.instances.length + ? 'no redundancy' + : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} + +
+ ) : undefined + } + > + + columns={instanceColumns as Column[]} + data={group.instances.map(i => ({ ...i, id: i.instanceId }))} + onRowClick={handleInstanceClick} + pageSize={50} + flush + /> + + ))} +
+ ) : ( +
+ {groups.map((group) => + expandedApps.has(group.appId) ? ( +
+ + + +
+ } + meta={ +
+ {group.totalTps.toFixed(1)} msg/s + {group.totalActiveRoutes}/{group.totalRoutes} routes + + + +
+ } + footer={ + group.deadCount > 0 ? ( +
+ + + Single point of failure —{' '} + {group.deadCount === group.instances.length + ? 'no redundancy' + : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} + +
+ ) : undefined + } + > + + columns={instanceColumns as Column[]} + data={group.instances.map(i => ({ ...i, id: i.instanceId }))} + onRowClick={handleInstanceClick} + pageSize={50} + flush + /> + +
+ ) : ( + toggleAppExpanded(group.appId)} + /> + ), + )} +
+ )} {/* Log + Timeline side by side */}
From 61df59853b79090b13ff65382d6b1438cb7cf99d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:39:48 +0200 Subject: [PATCH 06/19] feat: add expand/collapse animation for compact card toggle Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/AgentHealth/AgentHealth.tsx | 68 ++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 0087b644..dcb6048a 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown } from 'lucide-react'; import { @@ -179,6 +179,57 @@ export default function AgentHealth() { }); }, []); + const [animatingApps, setAnimatingApps] = useState>(new Map()); + const animationTimers = useRef>>(new Map()); + + const animateToggle = useCallback((appIdToToggle: string) => { + // Clear any existing timer for this app + const existing = animationTimers.current.get(appIdToToggle); + if (existing) clearTimeout(existing); + + const isCurrentlyExpanded = expandedApps.has(appIdToToggle); + + if (isCurrentlyExpanded) { + // Collapsing: start animation, then remove from expandedApps after transition + setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'collapsing')); + const timer = setTimeout(() => { + setExpandedApps((prev) => { + const next = new Set(prev); + next.delete(appIdToToggle); + return next; + }); + setAnimatingApps((prev) => { + const next = new Map(prev); + next.delete(appIdToToggle); + return next; + }); + animationTimers.current.delete(appIdToToggle); + }, 200); + animationTimers.current.set(appIdToToggle, timer); + } else { + // Expanding: add to expandedApps immediately, animate in + setExpandedApps((prev) => new Set(prev).add(appIdToToggle)); + setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'expanding')); + // Use requestAnimationFrame to ensure the collapsed state renders first + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setAnimatingApps((prev) => { + const next = new Map(prev); + next.delete(appIdToToggle); + return next; + }); + }); + }); + } + }, [expandedApps]); + + // Cleanup timers on unmount + useEffect(() => { + return () => { + animationTimers.current.forEach((timer) => clearTimeout(timer)); + }; + }, []); + const [configEditing, setConfigEditing] = useState(false); const [configDraft, setConfigDraft] = useState>({}); @@ -653,7 +704,16 @@ export default function AgentHealth() {
{groups.map((group) => expandedApps.has(group.appId) ? ( -
+
+ + {/* View toolbar */} +
-
- } - meta={ -
- {group.totalTps.toFixed(1)} msg/s - {group.totalActiveRoutes}/{group.totalRoutes} routes - - - -
- } - footer={ - group.deadCount > 0 ? ( -
- - - Single point of failure —{' '} - {group.deadCount === group.instances.length - ? 'no redundancy' - : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} - -
- ) : undefined - } - > - - columns={instanceColumns as Column[]} - data={group.instances.map(i => ({ ...i, id: i.instanceId }))} - onRowClick={handleInstanceClick} - pageSize={50} - flush - /> - -
- ) : ( + {groups.map((group) => ( +
animateToggle(group.appId)} /> - ), - )} + {expandedApps.has(group.appId) && ( +
{ + if (!el) return; + // Constrain overlay within viewport + const rect = el.getBoundingClientRect(); + const vw = document.documentElement.clientWidth; + if (rect.right > vw - 16) { + el.style.left = 'auto'; + el.style.right = '0'; + } + if (rect.bottom > document.documentElement.clientHeight) { + const overflow = rect.bottom - document.documentElement.clientHeight + 16; + el.style.maxHeight = `${rect.height - overflow}px`; + el.style.overflowY = 'auto'; + } + }} + className={`${styles.compactGridExpanded} ${styles.expandWrapper} ${ + animatingApps.get(group.appId) === 'expanding' + ? styles.expandWrapperCollapsed + : animatingApps.get(group.appId) === 'collapsing' + ? styles.expandWrapperCollapsed + : styles.expandWrapperExpanded + }`} + > + + + +
+ } + meta={ +
+ {group.totalTps.toFixed(1)} msg/s + {group.totalActiveRoutes}/{group.totalRoutes} routes + + + +
+ } + footer={ + group.deadCount > 0 ? ( +
+ + + Single point of failure —{' '} + {group.deadCount === group.instances.length + ? 'no redundancy' + : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} + +
+ ) : undefined + } + > + + columns={instanceColumns as Column[]} + data={group.instances.map(i => ({ ...i, id: i.instanceId }))} + onRowClick={handleInstanceClick} + pageSize={50} + flush + /> + +
+ )} +
+ ))}
)} From 9d1cf7577ac7b382b30e2c0e4a8bd3458eedd18e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:52:42 +0200 Subject: [PATCH 09/19] feat: overlay z-index fix, app name navigation, TPS on compact cards - Bump overlay z-index to 100 so it renders above the sidebar - App name in compact card navigates to /runtime/{slug} on click - Add TPS (msg/s) as third metric on compact cards between live count and heartbeat Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/AgentHealth/AgentHealth.module.css | 13 ++++++++++++- ui/src/pages/AgentHealth/AgentHealth.tsx | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 37efb44b..b09ca181 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -292,6 +292,12 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + cursor: pointer; +} + +.compactCardName:hover { + text-decoration: underline; + color: var(--running); } .compactCardChevron { @@ -310,6 +316,11 @@ color: var(--card-accent); } +.compactCardTps { + font-family: var(--font-mono); + color: var(--text-muted); +} + .compactCardHeartbeat { color: var(--text-muted); } @@ -326,7 +337,7 @@ /* Expanded card overlay — floats from the clicked card */ .compactGridExpanded { position: absolute; - z-index: 10; + z-index: 100; top: 0; left: 0; min-width: 500px; diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index bf0ea7af..bee2cbbc 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -106,7 +106,7 @@ const LOG_SOURCE_ITEMS: ButtonGroupItem[] = [ { value: 'container', label: 'Container' }, ]; -function CompactAppCard({ group, onExpand }: { group: AppGroup; onExpand: () => void }) { +function CompactAppCard({ group, onExpand, onNavigate }: { group: AppGroup; onExpand: () => void; onNavigate: () => void }) { const health = appHealth(group); const heartbeat = latestHeartbeat(group); const isHealthy = health === 'success'; @@ -125,13 +125,22 @@ function CompactAppCard({ group, onExpand }: { group: AppGroup; onExpand: () => onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onExpand(); }} >
- {group.appId} + { e.stopPropagation(); onNavigate(); }} + role="link" + > + {group.appId} +
{group.liveCount}/{group.instances.length} live + + {group.totalTps.toFixed(1)}/s + {heartbeat ? timeAgo(heartbeat) : '\u2014'} @@ -711,6 +720,7 @@ export default function AgentHealth() { animateToggle(group.appId)} + onNavigate={() => navigate(`/runtime/${group.appId}`)} /> {expandedApps.has(group.appId) && (
Date: Thu, 16 Apr 2026 13:59:25 +0200 Subject: [PATCH 10/19] feat: hide toggle on app detail, left-align toolbar, TPS unit fix - Move view toggle into compact grid conditional so it only renders on the overview page (not app detail /runtime/{slug}) - Left-align the toolbar buttons - Change TPS format from "x.y/s" to "x.y tps" Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pages/AgentHealth/AgentHealth.module.css | 2 +- ui/src/pages/AgentHealth/AgentHealth.tsx | 42 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index b09ca181..be507e67 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -369,7 +369,7 @@ .viewToolbar { display: flex; align-items: center; - justify-content: flex-end; + justify-content: flex-start; gap: 8px; margin-bottom: 12px; } diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index bee2cbbc..147dc914 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -139,7 +139,7 @@ function CompactAppCard({ group, onExpand, onNavigate }: { group: AppGroup; onEx {group.liveCount}/{group.instances.length} live - {group.totalTps.toFixed(1)}/s + {group.totalTps.toFixed(1)} tps {heartbeat ? timeAgo(heartbeat) : '\u2014'} @@ -526,26 +526,6 @@ export default function AgentHealth() { />
- {/* View toolbar */} -
-
- - -
-
- {/* Application config bar */} {appId && appConfig && (
@@ -714,6 +694,25 @@ export default function AgentHealth() { ))}
) : ( + <> +
+
+ + +
+
{groups.map((group) => (
@@ -810,6 +809,7 @@ export default function AgentHealth() {
))}
+ )} {/* Log + Timeline side by side */} From b57fe875f3db9050f6c3ed8dfd3027b673dba9c4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:04:26 +0200 Subject: [PATCH 11/19] feat: click-outside dismiss and clean overlay styling - Add invisible backdrop (z-index 99) behind expanded overlay to dismiss on outside click - Remove background/padding from overlay wrapper so GroupCard renders without visible extra border - Use drop-shadow filter instead of box-shadow for natural card shadow Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/AgentHealth/AgentHealth.module.css | 12 ++++++++---- ui/src/pages/AgentHealth/AgentHealth.tsx | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index be507e67..afe43c5f 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -341,10 +341,14 @@ top: 0; left: 0; min-width: 500px; - background: var(--bg-body); - border-radius: var(--radius-md); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - padding: 4px; + filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); +} + +/* Click-outside backdrop for expanded overlay */ +.overlayBackdrop { + position: fixed; + inset: 0; + z-index: 99; } /* Expand/collapse animation wrapper */ diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 147dc914..ec9ecfae 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -722,6 +722,8 @@ export default function AgentHealth() { onNavigate={() => navigate(`/runtime/${group.appId}`)} /> {expandedApps.has(group.appId) && ( + <> +
animateToggle(group.appId)} />
{ if (!el) return; @@ -758,7 +760,7 @@ export default function AgentHealth() { />
+ )}
))} From 4b264b33088e92c469526c28fa41b184404032cc Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:12:23 +0200 Subject: [PATCH 12/19] feat: add CPU usage to agent response and compact cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add cpuUsage field to AgentInstanceResponse (-1 if unavailable) - Add queryAgentCpuUsage() to AgentRegistrationController — queries avg CPU per instance from agent_metrics over last 2 minutes - Wire CPU into agent list response via withCpuUsage() Frontend: - Add cpuUsage to schema.d.ts - Compute maxCpu per AppGroup (max across all instances) - Show "X% cpu" on compact cards when available (hidden when -1) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AgentRegistrationController.java | 28 ++++++++++++++++++- .../server/app/dto/AgentInstanceResponse.java | 16 +++++++++-- ui/src/api/schema.d.ts | 2 ++ .../pages/AgentHealth/AgentHealth.module.css | 5 ++++ ui/src/pages/AgentHealth/AgentHealth.tsx | 7 +++++ 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java index 5ab042f6..185d45f2 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java @@ -329,6 +329,7 @@ public class AgentRegistrationController { // Enrich with runtime metrics from continuous aggregates Map agentMetrics = queryAgentMetrics(); + Map cpuByInstance = queryAgentCpuUsage(); final List finalAgents = agents; List response = finalAgents.stream() @@ -341,7 +342,11 @@ public class AgentRegistrationController { double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0; double errorRate = m[1]; int activeRoutes = (int) m[2]; - return dto.withMetrics(agentTps, errorRate, activeRoutes); + dto = dto.withMetrics(agentTps, errorRate, activeRoutes); + } + Double cpu = cpuByInstance.get(a.instanceId()); + if (cpu != null) { + dto = dto.withCpuUsage(cpu); } return dto; }) @@ -377,6 +382,27 @@ public class AgentRegistrationController { return result; } + /** Query average CPU usage per agent instance over the last 2 minutes. */ + private Map queryAgentCpuUsage() { + Map result = new HashMap<>(); + Instant now = Instant.now(); + Instant from2m = now.minus(2, ChronoUnit.MINUTES); + try { + jdbc.query( + "SELECT instance_id, avg(metric_value) AS cpu_avg " + + "FROM agent_metrics " + + "WHERE metric_name = 'process.cpu.usage.value'" + + " AND collected_at >= " + lit(from2m) + " AND collected_at < " + lit(now) + + " GROUP BY instance_id", + rs -> { + result.put(rs.getString("instance_id"), rs.getDouble("cpu_avg")); + }); + } catch (Exception e) { + log.debug("Could not query agent CPU usage: {}", e.getMessage()); + } + return result; + } + /** Format an Instant as a ClickHouse DateTime literal. */ private static String lit(Instant instant) { return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentInstanceResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentInstanceResponse.java index c30846b5..97c361c8 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentInstanceResponse.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentInstanceResponse.java @@ -25,7 +25,8 @@ public record AgentInstanceResponse( double errorRate, int activeRoutes, int totalRoutes, - long uptimeSeconds + long uptimeSeconds, + @Schema(description = "Recent average CPU usage (0.0–1.0), -1 if unavailable") double cpuUsage ) { public static AgentInstanceResponse from(AgentInfo info) { long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds(); @@ -37,7 +38,7 @@ public record AgentInstanceResponse( info.version(), info.capabilities(), 0.0, 0.0, 0, info.routeIds() != null ? info.routeIds().size() : 0, - uptime + uptime, -1 ); } @@ -46,7 +47,16 @@ public record AgentInstanceResponse( instanceId, displayName, applicationId, environmentId, status, routeIds, registeredAt, lastHeartbeat, version, capabilities, - tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds + tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, cpuUsage + ); + } + + public AgentInstanceResponse withCpuUsage(double cpuUsage) { + return new AgentInstanceResponse( + instanceId, displayName, applicationId, environmentId, + status, routeIds, registeredAt, lastHeartbeat, + version, capabilities, + tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, cpuUsage ); } } diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 732d591a..2c9a051c 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -2065,6 +2065,8 @@ export interface components { totalRoutes: number; /** Format: int64 */ uptimeSeconds: number; + /** Format: double */ + cpuUsage: number; }; SseEmitter: { /** Format: int64 */ diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index afe43c5f..cd77cabc 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -321,6 +321,11 @@ color: var(--text-muted); } +.compactCardCpu { + font-family: var(--font-mono); + color: var(--text-muted); +} + .compactCardHeartbeat { color: var(--text-muted); } diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index ec9ecfae..60f3b35e 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -52,6 +52,7 @@ interface AppGroup { totalTps: number; totalActiveRoutes: number; totalRoutes: number; + maxCpu: number; } function groupByApp(agentList: AgentInstance[]): AppGroup[] { @@ -71,6 +72,7 @@ function groupByApp(agentList: AgentInstance[]): AppGroup[] { totalTps: instances.reduce((s, i) => s + (i.tps ?? 0), 0), totalActiveRoutes: instances.reduce((s, i) => s + (i.activeRoutes ?? 0), 0), totalRoutes: instances.reduce((s, i) => s + (i.totalRoutes ?? 0), 0), + maxCpu: Math.max(...instances.map((i) => (i as AgentInstance & { cpuUsage?: number }).cpuUsage ?? -1)), })); } @@ -141,6 +143,11 @@ function CompactAppCard({ group, onExpand, onNavigate }: { group: AppGroup; onEx {group.totalTps.toFixed(1)} tps + {group.maxCpu >= 0 && ( + + {(group.maxCpu * 100).toFixed(0)}% cpu + + )} {heartbeat ? timeAgo(heartbeat) : '\u2014'} From 7825aae274d62ba2b2bdcd5790ffe7f789e2354e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:18:48 +0200 Subject: [PATCH 13/19] feat: show CPU usage in expanded GroupCard meta headers Add max CPU percentage to the meta row of both the full expanded view and the overlay expanded card, consistent with compact cards. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/AgentHealth/AgentHealth.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 60f3b35e..802ab096 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -663,6 +663,7 @@ export default function AgentHealth() {
{group.totalTps.toFixed(1)} msg/s {group.totalActiveRoutes}/{group.totalRoutes} routes + {group.maxCpu >= 0 && {(group.maxCpu * 100).toFixed(0)}% cpu} {group.totalTps.toFixed(1)} msg/s {group.totalActiveRoutes}/{group.totalRoutes} routes + {group.maxCpu >= 0 && {(group.maxCpu * 100).toFixed(0)}% cpu} Date: Thu, 16 Apr 2026 14:20:45 +0200 Subject: [PATCH 14/19] =?UTF-8?q?fix:=20resolve=20TS2367=20=E2=80=94=20vie?= =?UTF-8?q?w=20toggle=20active=20class=20in=20compact-only=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle only renders inside the compact branch, so viewMode is always 'compact' there. Use static class assignment instead of a comparison TypeScript correctly flags as unreachable. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/AgentHealth/AgentHealth.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 802ab096..ecfa47cb 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -706,14 +706,14 @@ export default function AgentHealth() {
+ +
+
+ )} + {/* Group cards grid */} {viewMode === 'expanded' || isFullWidth ? (
@@ -705,25 +727,6 @@ export default function AgentHealth() { ))}
) : ( - <> -
-
- - -
-
{groups.map((group) => (
@@ -824,7 +827,6 @@ export default function AgentHealth() {
))}
- )} {/* Log + Timeline side by side */} From e346b9bb9d3946c9af250585dbb37604b4d8e9b2 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:16:28 +0200 Subject: [PATCH 17/19] feat: add app name filter to runtime toolbar Text input next to view toggle filters apps by name (case-insensitive substring match). KPI stat strip uses unfiltered counts so totals stay accurate. Clear button on non-empty input. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pages/AgentHealth/AgentHealth.module.css | 47 ++++++++++++++++++- ui/src/pages/AgentHealth/AgentHealth.tsx | 33 +++++++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index cd77cabc..1e060d06 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -379,10 +379,55 @@ display: flex; align-items: center; justify-content: flex-start; - gap: 8px; + gap: 12px; margin-bottom: 12px; } +/* App name filter */ +.appFilterWrap { + position: relative; + display: flex; + align-items: center; +} + +.appFilterInput { + width: 180px; + height: 28px; + padding: 0 24px 0 8px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + font-size: 12px; + font-family: var(--font-body); + outline: none; + transition: border-color 150ms ease; +} + +.appFilterInput::placeholder { + color: var(--text-muted); +} + +.appFilterInput:focus { + border-color: var(--running); +} + +.appFilterClear { + position: absolute; + right: 4px; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 2px; +} + +.appFilterClear:hover { + color: var(--text-primary); +} + /* View mode toggle */ .viewToggle { display: flex; diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index c3f1ce04..afb3511e 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -284,6 +284,7 @@ export default function AgentHealth() { const [eventRefreshTo, setEventRefreshTo] = useState(); const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo, selectedEnv); + const [appFilter, setAppFilter] = useState(''); const [logSearch, setLogSearch] = useState(''); const [logLevels, setLogLevels] = useState>(new Set()); const [logSource, setLogSource] = useState(''); // '' = all, 'app', 'agent' @@ -305,7 +306,9 @@ export default function AgentHealth() { const agentList = agents ?? []; - const groups = useMemo(() => groupByApp(agentList).sort((a, b) => a.appId.localeCompare(b.appId)), [agentList]); + const allGroups = useMemo(() => groupByApp(agentList).sort((a, b) => a.appId.localeCompare(b.appId)), [agentList]); + const appFilterLower = appFilter.toLowerCase(); + const groups = appFilterLower ? allGroups.filter((g) => g.appId.toLowerCase().includes(appFilterLower)) : allGroups; // Aggregate stats const totalInstances = agentList.length; @@ -486,18 +489,18 @@ export default function AgentHealth() { /> - {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy + {allGroups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy - {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded + {allGroups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded - {groups.filter((g) => g.deadCount > 0).length} critical + {allGroups.filter((g) => g.deadCount > 0).length} critical
} @@ -666,6 +669,26 @@ export default function AgentHealth() {
+
+ setAppFilter(e.target.value)} + aria-label="Filter applications" + /> + {appFilter && ( + + )} +
)} From e84822f211c0aa538c2348752c6288cd0681f8e1 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:19:10 +0200 Subject: [PATCH 18/19] feat: add sort buttons and fix filter placeholder Add sort buttons (Status, Name, TPS, CPU, Heartbeat) to the toolbar, right-aligned. Clicking toggles asc/desc, second sort criterion is always name. Status sorts error > warning > success. Fix trailing unicode escape in filter placeholder. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pages/AgentHealth/AgentHealth.module.css | 36 +++++++++++ ui/src/pages/AgentHealth/AgentHealth.tsx | 61 +++++++++++++++++-- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 1e060d06..98041c8d 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -428,6 +428,42 @@ color: var(--text-primary); } +/* Sort buttons */ +.sortGroup { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; +} + +.sortBtn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 4px 8px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 11px; + font-family: var(--font-body); + cursor: pointer; + transition: color 150ms ease, border-color 150ms ease; + white-space: nowrap; +} + +.sortBtn:hover { + color: var(--text-primary); + border-color: var(--border-subtle); +} + +.sortBtnActive { + color: var(--text-primary); + border-color: var(--border-subtle); + background: var(--bg-raised); + font-weight: 600; +} + /* View mode toggle */ .viewToggle { display: flex; diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index afb3511e..103c6fb5 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown } from 'lucide-react'; +import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown, ArrowUp, ArrowDown } from 'lucide-react'; import { StatCard, StatusDot, Badge, MonoText, GroupCard, DataTable, EventFeed, @@ -285,6 +285,19 @@ export default function AgentHealth() { const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo, selectedEnv); const [appFilter, setAppFilter] = useState(''); + type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat'; + const [appSortKey, setAppSortKey] = useState('name'); + const [appSortAsc, setAppSortAsc] = useState(true); + + const cycleSort = useCallback((key: AppSortKey) => { + if (appSortKey === key) { + setAppSortAsc((prev) => !prev); + } else { + setAppSortKey(key); + setAppSortAsc(key === 'name'); // name defaults asc, others desc + } + }, [appSortKey]); + const [logSearch, setLogSearch] = useState(''); const [logLevels, setLogLevels] = useState>(new Set()); const [logSource, setLogSource] = useState(''); // '' = all, 'app', 'agent' @@ -306,9 +319,34 @@ export default function AgentHealth() { const agentList = agents ?? []; - const allGroups = useMemo(() => groupByApp(agentList).sort((a, b) => a.appId.localeCompare(b.appId)), [agentList]); + const allGroups = useMemo(() => groupByApp(agentList), [agentList]); + + const sortedGroups = useMemo(() => { + const healthPriority = (g: AppGroup) => g.deadCount > 0 ? 0 : g.staleCount > 0 ? 1 : 2; + const nameCmp = (a: AppGroup, b: AppGroup) => a.appId.localeCompare(b.appId); + + const sorted = [...allGroups].sort((a, b) => { + let cmp = 0; + switch (appSortKey) { + case 'status': cmp = healthPriority(a) - healthPriority(b); break; + case 'name': cmp = a.appId.localeCompare(b.appId); break; + case 'tps': cmp = a.totalTps - b.totalTps; break; + case 'cpu': cmp = a.maxCpu - b.maxCpu; break; + case 'heartbeat': { + const ha = latestHeartbeat(a) ?? ''; + const hb = latestHeartbeat(b) ?? ''; + cmp = ha < hb ? -1 : ha > hb ? 1 : 0; + break; + } + } + if (!appSortAsc) cmp = -cmp; + return cmp !== 0 ? cmp : nameCmp(a, b); + }); + return sorted; + }, [allGroups, appSortKey, appSortAsc]); + const appFilterLower = appFilter.toLowerCase(); - const groups = appFilterLower ? allGroups.filter((g) => g.appId.toLowerCase().includes(appFilterLower)) : allGroups; + const groups = appFilterLower ? sortedGroups.filter((g) => g.appId.toLowerCase().includes(appFilterLower)) : sortedGroups; // Aggregate stats const totalInstances = agentList.length; @@ -673,7 +711,7 @@ export default function AgentHealth() { setAppFilter(e.target.value)} aria-label="Filter applications" @@ -689,6 +727,21 @@ export default function AgentHealth() { )}
+
+ {([['status', 'Status'], ['name', 'Name'], ['tps', 'TPS'], ['cpu', 'CPU'], ['heartbeat', 'Heartbeat']] as const).map(([key, label]) => ( + + ))} +
)} From 3a9f3f41dee31d4b9c0dee8913c93468f0165baa Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:22:23 +0200 Subject: [PATCH 19/19] feat: match filter input to sidebar search styling Add search icon, translucent background, and same padding/sizing as the sidebar's built-in filter input. Placeholder changed to "Filter..." to match sidebar convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pages/AgentHealth/AgentHealth.module.css | 19 +++++++++++++------ ui/src/pages/AgentHealth/AgentHealth.tsx | 5 +++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 98041c8d..20e036ad 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -383,20 +383,27 @@ margin-bottom: 12px; } -/* App name filter */ +/* App name filter — matches sidebar search input styling */ .appFilterWrap { position: relative; display: flex; align-items: center; } +.appFilterIcon { + position: absolute; + left: 8px; + color: var(--text-muted); + pointer-events: none; +} + .appFilterInput { width: 180px; - height: 28px; - padding: 0 24px 0 8px; - border: 1px solid var(--border-subtle); + height: 29px; + padding: 6px 26px 6px 28px; + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: var(--radius-sm); - background: transparent; + background: rgba(255, 255, 255, 0.06); color: var(--text-primary); font-size: 12px; font-family: var(--font-body); @@ -414,7 +421,7 @@ .appFilterClear { position: absolute; - right: 4px; + right: 6px; background: transparent; border: none; color: var(--text-muted); diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 103c6fb5..d84ce777 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown, ArrowUp, ArrowDown } from 'lucide-react'; +import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Search } from 'lucide-react'; import { StatCard, StatusDot, Badge, MonoText, GroupCard, DataTable, EventFeed, @@ -708,10 +708,11 @@ export default function AgentHealth() {
+ setAppFilter(e.target.value)} aria-label="Filter applications"