Merge pull request 'feat/runtime-compact-view' (#136) from feat/runtime-compact-view into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m20s
CI / docker (push) Successful in 30s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

Reviewed-on: #136
This commit is contained in:
2026-04-16 15:27:37 +02:00
8 changed files with 1572 additions and 70 deletions

View File

@@ -9,7 +9,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
- **Exchanges** — route execution search and detail (`ui/src/pages/Exchanges/`) - **Exchanges** — route execution search and detail (`ui/src/pages/Exchanges/`)
- **Dashboard** — metrics and stats with L1/L2/L3 drill-down (`ui/src/pages/DashboardTab/`) - **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/`) - **Deployments** — app management, JAR upload, deployment lifecycle (`ui/src/pages/AppsTab/`)
- Config sub-tabs: **Monitoring | Resources | Variables | Traces & Taps | Route Recording** - Config sub-tabs: **Monitoring | Resources | Variables | Traces & Taps | Route Recording**
- Create app: full page at `/apps/new` (not a modal) - Create app: full page at `/apps/new` (not a modal)

View File

@@ -329,6 +329,7 @@ public class AgentRegistrationController {
// Enrich with runtime metrics from continuous aggregates // Enrich with runtime metrics from continuous aggregates
Map<String, double[]> agentMetrics = queryAgentMetrics(); Map<String, double[]> agentMetrics = queryAgentMetrics();
Map<String, Double> cpuByInstance = queryAgentCpuUsage();
final List<AgentInfo> finalAgents = agents; final List<AgentInfo> finalAgents = agents;
List<AgentInstanceResponse> response = finalAgents.stream() List<AgentInstanceResponse> response = finalAgents.stream()
@@ -341,7 +342,11 @@ public class AgentRegistrationController {
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0; double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
double errorRate = m[1]; double errorRate = m[1];
int activeRoutes = (int) m[2]; 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; return dto;
}) })
@@ -377,6 +382,27 @@ public class AgentRegistrationController {
return result; return result;
} }
/** Query average CPU usage per agent instance over the last 2 minutes. */
private Map<String, Double> queryAgentCpuUsage() {
Map<String, Double> 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. */ /** Format an Instant as a ClickHouse DateTime literal. */
private static String lit(Instant instant) { private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

View File

@@ -25,7 +25,8 @@ public record AgentInstanceResponse(
double errorRate, double errorRate,
int activeRoutes, int activeRoutes,
int totalRoutes, int totalRoutes,
long uptimeSeconds long uptimeSeconds,
@Schema(description = "Recent average CPU usage (0.01.0), -1 if unavailable") double cpuUsage
) { ) {
public static AgentInstanceResponse from(AgentInfo info) { public static AgentInstanceResponse from(AgentInfo info) {
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds(); long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
@@ -37,7 +38,7 @@ public record AgentInstanceResponse(
info.version(), info.capabilities(), info.version(), info.capabilities(),
0.0, 0.0, 0.0, 0.0,
0, info.routeIds() != null ? info.routeIds().size() : 0, 0, info.routeIds() != null ? info.routeIds().size() : 0,
uptime uptime, -1
); );
} }
@@ -46,7 +47,16 @@ public record AgentInstanceResponse(
instanceId, displayName, applicationId, environmentId, instanceId, displayName, applicationId, environmentId,
status, routeIds, registeredAt, lastHeartbeat, status, routeIds, registeredAt, lastHeartbeat,
version, capabilities, 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
); );
} }
} }

View File

@@ -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<Set<string>>(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 `<div className={styles.statStrip}>`, after the last `<StatCard>` (the "Dead" one ending around line 401), add:
```tsx
<div className={styles.viewToggle}>
<button
className={`${styles.viewToggleBtn} ${viewMode === 'compact' ? styles.viewToggleBtnActive : ''}`}
onClick={() => toggleViewMode('compact')}
title="Compact view"
>
<LayoutGrid size={14} />
</button>
<button
className={`${styles.viewToggleBtn} ${viewMode === 'expanded' ? styles.viewToggleBtnActive : ''}`}
onClick={() => toggleViewMode('expanded')}
title="Expanded view"
>
<List size={14} />
</button>
</div>
```
- [ ] **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 (
<div
className={`${styles.compactCard} ${variantClass}`}
onClick={onExpand}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onExpand(); }}
>
<div className={styles.compactCardHeader}>
<span className={styles.compactCardName}>{group.appId}</span>
<ChevronRight size={14} className={styles.compactCardChevron} />
</div>
<div className={styles.compactCardMeta}>
<span className={styles.compactCardLive}>
{group.liveCount}/{group.instances.length} live
</span>
<span className={isHealthy ? styles.compactCardHeartbeat : styles.compactCardHeartbeatWarn}>
{heartbeat ? timeAgo(heartbeat) : '\u2014'}
</span>
</div>
</div>
);
}
```
- [ ] **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 */}
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
{groups.map((group) => (
<GroupCard
...
</GroupCard>
))}
</div>
```
with:
```tsx
{/* Group cards grid */}
{viewMode === 'expanded' || isFullWidth ? (
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
{groups.map((group) => (
<GroupCard
key={group.appId}
title={group.appId}
accent={appHealth(group)}
headerRight={
<Badge
label={`${group.liveCount}/${group.instances.length} LIVE`}
color={appHealth(group)}
variant="filled"
/>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
<span>
<StatusDot
variant={
appHealth(group) === 'success'
? 'live'
: appHealth(group) === 'warning'
? 'stale'
: 'dead'
}
/>
</span>
</div>
}
footer={
group.deadCount > 0 ? (
<div className={styles.alertBanner}>
<span className={styles.alertIcon}>&#9888;</span>
<span>
Single point of failure &mdash;{' '}
{group.deadCount === group.instances.length
? 'no redundancy'
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
</span>
</div>
) : undefined
}
>
<DataTable<AgentInstance & { id: string }>
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
onRowClick={handleInstanceClick}
pageSize={50}
flush
/>
</GroupCard>
))}
</div>
) : (
<div className={styles.compactGrid}>
{groups.map((group) =>
expandedApps.has(group.appId) ? (
<div key={group.appId} className={styles.compactGridExpanded}>
<GroupCard
title={group.appId}
accent={appHealth(group)}
headerRight={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Badge
label={`${group.liveCount}/${group.instances.length} LIVE`}
color={appHealth(group)}
variant="filled"
/>
<button
className={styles.collapseBtn}
onClick={() => toggleAppExpanded(group.appId)}
title="Collapse"
>
<ChevronDown size={14} />
</button>
</div>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
<span>
<StatusDot
variant={
appHealth(group) === 'success'
? 'live'
: appHealth(group) === 'warning'
? 'stale'
: 'dead'
}
/>
</span>
</div>
}
footer={
group.deadCount > 0 ? (
<div className={styles.alertBanner}>
<span className={styles.alertIcon}>&#9888;</span>
<span>
Single point of failure &mdash;{' '}
{group.deadCount === group.instances.length
? 'no redundancy'
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
</span>
</div>
) : undefined
}
>
<DataTable<AgentInstance & { id: string }>
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
onRowClick={handleInstanceClick}
pageSize={50}
flush
/>
</GroupCard>
</div>
) : (
<CompactAppCard
key={group.appId}
group={group}
onExpand={() => toggleAppExpanded(group.appId)}
/>
),
)}
</div>
)}
```
- [ ] **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<Map<string, 'expanding' | 'collapsing'>>(new Map());
const animationTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(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) ? (
<div key={group.appId} className={styles.compactGridExpanded}>
<GroupCard
```
to:
```tsx
expandedApps.has(group.appId) ? (
<div
key={group.appId}
className={`${styles.compactGridExpanded} ${styles.expandWrapper} ${
animatingApps.get(group.appId) === 'expanding'
? styles.expandWrapperCollapsed
: animatingApps.get(group.appId) === 'collapsing'
? styles.expandWrapperCollapsed
: styles.expandWrapperExpanded
}`}
>
<GroupCard
```
Update the collapse button onClick:
```tsx
onClick={() => animateToggle(group.appId)}
```
Update the `CompactAppCard`'s `onExpand`:
```tsx
<CompactAppCard
key={group.appId}
group={group}
onExpand={() => 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"
```

View File

@@ -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<string>` — 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).

View File

@@ -2065,6 +2065,8 @@ export interface components {
totalRoutes: number; totalRoutes: number;
/** Format: int64 */ /** Format: int64 */
uptimeSeconds: number; uptimeSeconds: number;
/** Format: double */
cpuUsage: number;
}; };
SseEmitter: { SseEmitter: {
/** Format: int64 */ /** Format: int64 */

View File

@@ -93,6 +93,15 @@
margin-bottom: 20px; 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 */ /* Group meta row */
.groupMeta { .groupMeta {
display: flex; display: flex;
@@ -250,3 +259,265 @@
border-bottom: 1px solid var(--border-subtle); 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);
}

View File

@@ -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 { 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 { import {
StatCard, StatusDot, Badge, MonoText, StatCard, StatusDot, Badge, MonoText,
GroupCard, DataTable, EventFeed, GroupCard, DataTable, EventFeed,
@@ -52,6 +52,7 @@ interface AppGroup {
totalTps: number; totalTps: number;
totalActiveRoutes: number; totalActiveRoutes: number;
totalRoutes: number; totalRoutes: number;
maxCpu: number;
} }
function groupByApp(agentList: AgentInstance[]): AppGroup[] { function groupByApp(agentList: AgentInstance[]): AppGroup[] {
@@ -71,6 +72,7 @@ function groupByApp(agentList: AgentInstance[]): AppGroup[] {
totalTps: instances.reduce((s, i) => s + (i.tps ?? 0), 0), totalTps: instances.reduce((s, i) => s + (i.tps ?? 0), 0),
totalActiveRoutes: instances.reduce((s, i) => s + (i.activeRoutes ?? 0), 0), totalActiveRoutes: instances.reduce((s, i) => s + (i.activeRoutes ?? 0), 0),
totalRoutes: instances.reduce((s, i) => s + (i.totalRoutes ?? 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'; 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 ──────────────────────────────────────────────────── // ── Detail sub-components ────────────────────────────────────────────────────
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [ const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
@@ -96,6 +108,54 @@ const LOG_SOURCE_ITEMS: ButtonGroupItem[] = [
{ value: 'container', label: 'Container' }, { 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 (
<div
className={`${styles.compactCard} ${variantClass}`}
onClick={onExpand}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onExpand(); }}
>
<div className={styles.compactCardHeader}>
<span
className={styles.compactCardName}
onClick={(e) => { e.stopPropagation(); onNavigate(); }}
role="link"
>
{group.appId}
</span>
<ChevronRight size={14} className={styles.compactCardChevron} />
</div>
<div className={styles.compactCardMeta}>
<span className={styles.compactCardLive}>
{group.liveCount}/{group.instances.length} live
</span>
<span className={styles.compactCardTps}>
{group.totalTps.toFixed(1)} tps
</span>
{group.maxCpu >= 0 && (
<span className={styles.compactCardCpu}>
{(group.maxCpu * 100).toFixed(0)}% cpu
</span>
)}
<span className={isHealthy ? styles.compactCardHeartbeat : styles.compactCardHeartbeatWarn}>
{heartbeat ? timeAgo(heartbeat) : '\u2014'}
</span>
</div>
</div>
);
}
// ── AgentHealth page ───────────────────────────────────────────────────────── // ── AgentHealth page ─────────────────────────────────────────────────────────
export default function AgentHealth() { export default function AgentHealth() {
@@ -114,6 +174,78 @@ export default function AgentHealth() {
const catalogEntry = catalogApps?.find((a) => a.slug === appId); const catalogEntry = catalogApps?.find((a) => a.slug === appId);
const [confirmDismissOpen, setConfirmDismissOpen] = useState(false); 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<Set<string>>(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<Map<string, 'expanding' | 'collapsing'>>(new Map());
const animationTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(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 [configEditing, setConfigEditing] = useState(false);
const [configDraft, setConfigDraft] = useState<Record<string, string | boolean>>({}); const [configDraft, setConfigDraft] = useState<Record<string, string | boolean>>({});
@@ -152,6 +284,20 @@ export default function AgentHealth() {
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>(); const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo, selectedEnv); const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo, selectedEnv);
const [appFilter, setAppFilter] = useState('');
type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat';
const [appSortKey, setAppSortKey] = useState<AppSortKey>('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 [logSearch, setLogSearch] = useState('');
const [logLevels, setLogLevels] = useState<Set<string>>(new Set()); const [logLevels, setLogLevels] = useState<Set<string>>(new Set());
const [logSource, setLogSource] = useState<string>(''); // '' = all, 'app', 'agent' const [logSource, setLogSource] = useState<string>(''); // '' = all, 'app', 'agent'
@@ -173,7 +319,34 @@ export default function AgentHealth() {
const agentList = agents ?? []; 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 // Aggregate stats
const totalInstances = agentList.length; const totalInstances = agentList.length;
@@ -254,13 +427,16 @@ export default function AgentHealth() {
), ),
}, },
{ {
key: 'errorRate', key: 'cpuUsage',
header: 'Errors', header: 'CPU',
render: (_val, row) => ( render: (_val, row) => {
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}> const cpu = (row as AgentInstance & { cpuUsage?: number }).cpuUsage;
{formatErrorRate(row.errorRate)} return (
</MonoText> <MonoText size="xs" className={cpu != null && cpu > 0.8 ? styles.instanceError : styles.instanceMeta}>
), {cpu != null && cpu >= 0 ? `${(cpu * 100).toFixed(0)}%` : '\u2014'}
</MonoText>
);
},
}, },
{ {
key: 'lastHeartbeat', key: 'lastHeartbeat',
@@ -351,18 +527,18 @@ export default function AgentHealth() {
/> />
<StatCard <StatCard
label="Applications" label="Applications"
value={String(groups.length)} value={String(allGroups.length)}
accent="running" accent="running"
detail={ detail={
<span className={styles.breakdown}> <span className={styles.breakdown}>
<span className={styles.bpLive}> <span className={styles.bpLive}>
<StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy <StatusDot variant="live" /> {allGroups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
</span> </span>
<span className={styles.bpStale}> <span className={styles.bpStale}>
<StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded <StatusDot variant="stale" /> {allGroups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
</span> </span>
<span className={styles.bpDead}> <span className={styles.bpDead}>
<StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical <StatusDot variant="dead" /> {allGroups.filter((g) => g.deadCount > 0).length} critical
</span> </span>
</span> </span>
} }
@@ -512,61 +688,223 @@ export default function AgentHealth() {
)} )}
{/* View toolbar — hidden on app detail page */}
{!isFullWidth && (
<div className={styles.viewToolbar}>
<div className={styles.viewToggle}>
<button
className={`${styles.viewToggleBtn} ${viewMode === 'compact' ? styles.viewToggleBtnActive : ''}`}
onClick={() => toggleViewMode('compact')}
title="Compact view"
>
<LayoutGrid size={14} />
</button>
<button
className={`${styles.viewToggleBtn} ${viewMode === 'expanded' ? styles.viewToggleBtnActive : ''}`}
onClick={() => toggleViewMode('expanded')}
title="Expanded view"
>
<List size={14} />
</button>
</div>
<div className={styles.appFilterWrap}>
<Search size={12} className={styles.appFilterIcon} />
<input
type="text"
className={styles.appFilterInput}
placeholder="Filter..."
value={appFilter}
onChange={(e) => setAppFilter(e.target.value)}
aria-label="Filter applications"
/>
{appFilter && (
<button
type="button"
className={styles.appFilterClear}
onClick={() => setAppFilter('')}
aria-label="Clear filter"
>
&times;
</button>
)}
</div>
<div className={styles.sortGroup}>
{([['status', 'Status'], ['name', 'Name'], ['tps', 'TPS'], ['cpu', 'CPU'], ['heartbeat', 'Heartbeat']] as const).map(([key, label]) => (
<button
key={key}
className={`${styles.sortBtn} ${appSortKey === key ? styles.sortBtnActive : ''}`}
onClick={() => cycleSort(key)}
title={`Sort by ${label}`}
>
{label}
{appSortKey === key && (
appSortAsc ? <ArrowUp size={10} /> : <ArrowDown size={10} />
)}
</button>
))}
</div>
</div>
)}
{/* Group cards grid */} {/* Group cards grid */}
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}> {viewMode === 'expanded' || isFullWidth ? (
{groups.map((group) => ( <div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
<GroupCard {groups.map((group) => (
key={group.appId} <GroupCard
title={group.appId} key={group.appId}
accent={appHealth(group)} title={group.appId}
headerRight={ accent={appHealth(group)}
<Badge headerRight={
label={`${group.liveCount}/${group.instances.length} LIVE`} <Badge
color={appHealth(group)} label={`${group.liveCount}/${group.instances.length} LIVE`}
variant="filled" color={appHealth(group)}
/> variant="filled"
} />
meta={ }
<div className={styles.groupMeta}> meta={
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span> <div className={styles.groupMeta}>
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span> <span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
<span> <span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
<StatusDot {group.maxCpu >= 0 && <span><strong>{(group.maxCpu * 100).toFixed(0)}%</strong> cpu</span>}
variant={
appHealth(group) === 'success'
? 'live'
: appHealth(group) === 'warning'
? 'stale'
: 'dead'
}
/>
</span>
</div>
}
footer={
group.deadCount > 0 ? (
<div className={styles.alertBanner}>
<span className={styles.alertIcon}>&#9888;</span>
<span> <span>
Single point of failure &mdash;{' '} <StatusDot
{group.deadCount === group.instances.length variant={
? 'no redundancy' appHealth(group) === 'success'
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} ? 'live'
: appHealth(group) === 'warning'
? 'stale'
: 'dead'
}
/>
</span> </span>
</div> </div>
) : undefined }
} footer={
> group.deadCount > 0 ? (
<DataTable<AgentInstance & { id: string }> <div className={styles.alertBanner}>
columns={instanceColumns as Column<AgentInstance & { id: string }>[]} <span className={styles.alertIcon}>&#9888;</span>
data={group.instances.map(i => ({ ...i, id: i.instanceId }))} <span>
onRowClick={handleInstanceClick} Single point of failure &mdash;{' '}
pageSize={50} {group.deadCount === group.instances.length
flush ? 'no redundancy'
/> : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
</GroupCard> </span>
))} </div>
</div> ) : undefined
}
>
<DataTable<AgentInstance & { id: string }>
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
onRowClick={handleInstanceClick}
pageSize={50}
flush
/>
</GroupCard>
))}
</div>
) : (
<div className={styles.compactGrid}>
{groups.map((group) => (
<div key={group.appId} className={styles.compactGridCell}>
<CompactAppCard
group={group}
onExpand={() => animateToggle(group.appId)}
onNavigate={() => navigate(`/runtime/${group.appId}`)}
/>
{expandedApps.has(group.appId) && (
<>
<div className={styles.overlayBackdrop} onClick={() => animateToggle(group.appId)} />
<div
ref={(el) => {
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
}`}
>
<GroupCard
title={group.appId}
accent={appHealth(group)}
headerRight={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Badge
label={`${group.liveCount}/${group.instances.length} LIVE`}
color={appHealth(group)}
variant="filled"
/>
<button
className={styles.collapseBtn}
onClick={(e) => { e.stopPropagation(); animateToggle(group.appId); }}
title="Collapse"
>
<ChevronDown size={14} />
</button>
</div>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
{group.maxCpu >= 0 && <span><strong>{(group.maxCpu * 100).toFixed(0)}%</strong> cpu</span>}
<span>
<StatusDot
variant={
appHealth(group) === 'success'
? 'live'
: appHealth(group) === 'warning'
? 'stale'
: 'dead'
}
/>
</span>
</div>
}
footer={
group.deadCount > 0 ? (
<div className={styles.alertBanner}>
<span className={styles.alertIcon}>&#9888;</span>
<span>
Single point of failure &mdash;{' '}
{group.deadCount === group.instances.length
? 'no redundancy'
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
</span>
</div>
) : undefined
}
>
<DataTable<AgentInstance & { id: string }>
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
onRowClick={handleInstanceClick}
pageSize={50}
flush
/>
</GroupCard>
</div>
</>
)}
</div>
))}
</div>
)}
{/* Log + Timeline side by side */} {/* Log + Timeline side by side */}
<div className={styles.bottomRow}> <div className={styles.bottomRow}>