diff --git a/.claude/rules/ui.md b/.claude/rules/ui.md index 084ed9bb..8327d4ce 100644 --- a/.claude/rules/ui.md +++ b/.claude/rules/ui.md @@ -9,7 +9,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments - **Exchanges** — route execution search and detail (`ui/src/pages/Exchanges/`) - **Dashboard** — metrics and stats with L1/L2/L3 drill-down (`ui/src/pages/DashboardTab/`) -- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`) +- **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. - **Deployments** — app management, JAR upload, deployment lifecycle (`ui/src/pages/AppsTab/`) - Config sub-tabs: **Monitoring | Resources | Variables | Traces & Taps | Route Recording** - Create app: full page at `/apps/new` (not a modal) 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/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" +``` 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). 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 603d6794..20e036ad 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -93,6 +93,15 @@ margin-bottom: 20px; } +/* Compact view grid */ +.compactGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 20px; + position: relative; +} + /* Group meta row */ .groupMeta { display: flex; @@ -250,3 +259,265 @@ 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; + cursor: pointer; +} + +.compactCardName:hover { + text-decoration: underline; + color: var(--running); +} + +.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); +} + +.compactCardTps { + font-family: var(--font-mono); + color: var(--text-muted); +} + +.compactCardCpu { + font-family: var(--font-mono); + color: var(--text-muted); +} + +.compactCardHeartbeat { + color: var(--text-muted); +} + +.compactCardHeartbeatWarn { + color: var(--card-accent); +} + +/* Wrapper for each compact grid cell — anchor for overlay */ +.compactGridCell { + position: relative; +} + +/* Expanded card overlay — floats from the clicked card */ +.compactGridExpanded { + position: absolute; + z-index: 100; + top: 0; + left: 0; + min-width: 500px; + 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 */ +.expandWrapper { + overflow: hidden; + transition: opacity 200ms ease, transform 200ms ease; +} + +.expandWrapperCollapsed { + opacity: 0; + transform: scaleY(0.95); + transform-origin: top; +} + +.expandWrapperExpanded { + opacity: 1; + transform: scaleY(1); + transform-origin: top; +} + +/* View toolbar — between stat strip and cards grid */ +.viewToolbar { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + margin-bottom: 12px; +} + +/* 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: 29px; + padding: 6px 26px 6px 28px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.06); + 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: 6px; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 2px; +} + +.appFilterClear:hover { + 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; + 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); +} + diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 2e25fbd2..d84ce777 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 { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { ExternalLink, RefreshCw, Pencil } 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, @@ -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)), })); } @@ -80,6 +82,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 +108,54 @@ const LOG_SOURCE_ITEMS: ButtonGroupItem[] = [ { value: 'container', label: 'Container' }, ]; +function CompactAppCard({ group, onExpand, onNavigate }: { group: AppGroup; onExpand: () => void; onNavigate: () => 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(); }} + > +
+ { e.stopPropagation(); onNavigate(); }} + role="link" + > + {group.appId} + + +
+
+ + {group.liveCount}/{group.instances.length} live + + + {group.totalTps.toFixed(1)} tps + + {group.maxCpu >= 0 && ( + + {(group.maxCpu * 100).toFixed(0)}% cpu + + )} + + {heartbeat ? timeAgo(heartbeat) : '\u2014'} + +
+
+ ); +} + // ── AgentHealth page ───────────────────────────────────────────────────────── export default function AgentHealth() { @@ -114,6 +174,78 @@ 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 [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>({}); @@ -152,6 +284,20 @@ export default function AgentHealth() { const [eventRefreshTo, setEventRefreshTo] = useState(); 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' @@ -173,7 +319,34 @@ export default function AgentHealth() { const agentList = agents ?? []; - const groups = useMemo(() => groupByApp(agentList), [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 ? sortedGroups.filter((g) => g.appId.toLowerCase().includes(appFilterLower)) : sortedGroups; // Aggregate stats const totalInstances = agentList.length; @@ -254,13 +427,16 @@ export default function AgentHealth() { ), }, { - key: 'errorRate', - header: 'Errors', - render: (_val, row) => ( - - {formatErrorRate(row.errorRate)} - - ), + key: 'cpuUsage', + header: 'CPU', + render: (_val, row) => { + const cpu = (row as AgentInstance & { cpuUsage?: number }).cpuUsage; + return ( + 0.8 ? styles.instanceError : styles.instanceMeta}> + {cpu != null && cpu >= 0 ? `${(cpu * 100).toFixed(0)}%` : '\u2014'} + + ); + }, }, { key: 'lastHeartbeat', @@ -351,18 +527,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 } @@ -512,61 +688,223 @@ export default function AgentHealth() { )} + {/* View toolbar — hidden on app detail page */} + {!isFullWidth && ( +
+
+ + +
+
+ + setAppFilter(e.target.value)} + aria-label="Filter applications" + /> + {appFilter && ( + + )} +
+
+ {([['status', 'Status'], ['name', 'Name'], ['tps', 'TPS'], ['cpu', 'CPU'], ['heartbeat', 'Heartbeat']] as const).map(([key, label]) => ( + + ))} +
+
+ )} + {/* 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 + {group.maxCpu >= 0 && {(group.maxCpu * 100).toFixed(0)}% cpu} - 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) => ( +
+ animateToggle(group.appId)} + onNavigate={() => navigate(`/runtime/${group.appId}`)} + /> + {expandedApps.has(group.appId) && ( + <> +
animateToggle(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 + {group.maxCpu >= 0 && {(group.maxCpu * 100).toFixed(0)}% cpu} + + + +
+ } + 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 + /> + +
+ + )} +
+ ))} +
+ )} {/* Log + Timeline side by side */}