Compare commits

..

20 Commits

Author SHA1 Message Date
11ad769f59 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
2026-04-16 15:27:37 +02:00
hsiegeln
3a9f3f41de feat: match filter input to sidebar search styling
All checks were successful
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m11s
CI / cleanup-branch (push) Has been skipped
CI / docker (pull_request) Has been skipped
CI / build (push) Successful in 2m11s
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m11s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 40s
Add search icon, translucent background, and same padding/sizing
as the sidebar's built-in filter input. Placeholder changed to
"Filter..." to match sidebar convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:22:23 +02:00
hsiegeln
e84822f211 feat: add sort buttons and fix filter placeholder
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (pull_request) Has been skipped
CI / build (push) Successful in 2m13s
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / docker (push) Has been cancelled
Add sort buttons (Status, Name, TPS, CPU, Heartbeat) to the toolbar,
right-aligned. Clicking toggles asc/desc, second sort criterion is
always name. Status sorts error > warning > success. Fix trailing
unicode escape in filter placeholder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:19:10 +02:00
hsiegeln
e346b9bb9d feat: add app name filter to runtime toolbar
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m9s
CI / cleanup-branch (push) Has been skipped
CI / docker (pull_request) Has been skipped
CI / build (push) Successful in 2m10s
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / docker (push) Has been cancelled
Text input next to view toggle filters apps by name (case-insensitive
substring match). KPI stat strip uses unfiltered counts so totals
stay accurate. Clear button on non-empty input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:16:28 +02:00
hsiegeln
f4811359e1 fix: keep view toggle visible in both compact and expanded modes
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m16s
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m12s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
Move toolbar above the grid conditional so it renders in both
view modes. Hidden only on app detail pages (isFullWidth).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:13:47 +02:00
hsiegeln
e5b1171833 feat: replace Errors column with CPU in expanded agent table
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (push) Has been cancelled
CI / build (push) Has been cancelled
CI / build (pull_request) Successful in 2m0s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Show per-instance CPU usage percentage instead of error rate in the
DataTable. Highlights >80% CPU in error color.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:12:01 +02:00
hsiegeln
d27a288128 fix: resolve TS2367 — view toggle active class in compact-only branch
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m32s
CI / cleanup-branch (pull_request) Has been skipped
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / build (pull_request) Successful in 2m15s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
The toggle only renders inside the compact branch, so viewMode is
always 'compact' there. Use static class assignment instead of a
comparison TypeScript correctly flags as unreachable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:20:45 +02:00
hsiegeln
7825aae274 feat: show CPU usage in expanded GroupCard meta headers
Add max CPU percentage to the meta row of both the full expanded
view and the overlay expanded card, consistent with compact cards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:18:48 +02:00
hsiegeln
4b264b3308 feat: add CPU usage to agent response and compact cards
Backend:
- Add cpuUsage field to AgentInstanceResponse (-1 if unavailable)
- Add queryAgentCpuUsage() to AgentRegistrationController — queries
  avg CPU per instance from agent_metrics over last 2 minutes
- Wire CPU into agent list response via withCpuUsage()

Frontend:
- Add cpuUsage to schema.d.ts
- Compute maxCpu per AppGroup (max across all instances)
- Show "X% cpu" on compact cards when available (hidden when -1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:12:23 +02:00
hsiegeln
b57fe875f3 feat: click-outside dismiss and clean overlay styling
- Add invisible backdrop (z-index 99) behind expanded overlay to
  dismiss on outside click
- Remove background/padding from overlay wrapper so GroupCard
  renders without visible extra border
- Use drop-shadow filter instead of box-shadow for natural card
  shadow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:04:26 +02:00
hsiegeln
911ba591a9 feat: hide toggle on app detail, left-align toolbar, TPS unit fix
- Move view toggle into compact grid conditional so it only renders
  on the overview page (not app detail /runtime/{slug})
- Left-align the toolbar buttons
- Change TPS format from "x.y/s" to "x.y tps"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:59:25 +02:00
hsiegeln
9d1cf7577a feat: overlay z-index fix, app name navigation, TPS on compact cards
- Bump overlay z-index to 100 so it renders above the sidebar
- App name in compact card navigates to /runtime/{slug} on click
- Add TPS (msg/s) as third metric on compact cards between live
  count and heartbeat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:52:42 +02:00
hsiegeln
1fa897fbb5 feat: move toggle to toolbar, sort apps by name, overlay expand
- Move expand/collapse toggle from stat strip to dedicated toolbar
  below KPIs
- Sort app groups alphabetically by name
- Expanded card overlays from clicked card position instead of
  pushing other cards down
- Viewport constraint: overlay flips right-alignment and limits
  height when near edges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:48:39 +02:00
hsiegeln
9f7951aa2b docs: add compact view to runtime section of ui rules
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:42:26 +02:00
hsiegeln
61df59853b feat: add expand/collapse animation for compact card toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:39:48 +02:00
hsiegeln
5229e08b27 feat: add compact app cards with inline expand to runtime dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:37:58 +02:00
hsiegeln
d0c2fd1ac3 feat: add view mode state and toggle to runtime dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:37:16 +02:00
hsiegeln
5c94881608 style: add compact view CSS classes for runtime dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:35:33 +02:00
hsiegeln
23d24487d1 docs: add runtime compact view implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:32:14 +02:00
hsiegeln
bf289aa1b1 docs: add runtime compact view design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:27:53 +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/`)
- **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)

View File

@@ -329,6 +329,7 @@ public class AgentRegistrationController {
// Enrich with runtime metrics from continuous aggregates
Map<String, double[]> agentMetrics = queryAgentMetrics();
Map<String, Double> cpuByInstance = queryAgentCpuUsage();
final List<AgentInfo> finalAgents = agents;
List<AgentInstanceResponse> 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<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. */
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

View File

@@ -25,7 +25,8 @@ public record AgentInstanceResponse(
double errorRate,
int activeRoutes,
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) {
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
);
}
}

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;
/** Format: int64 */
uptimeSeconds: number;
/** Format: double */
cpuUsage: number;
};
SseEmitter: {
/** Format: int64 */

View File

@@ -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);
}

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 { 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 (
<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 ─────────────────────────────────────────────────────────
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<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 [configDraft, setConfigDraft] = useState<Record<string, string | boolean>>({});
@@ -152,6 +284,20 @@ export default function AgentHealth() {
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
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 [logLevels, setLogLevels] = useState<Set<string>>(new Set());
const [logSource, setLogSource] = useState<string>(''); // '' = 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) => (
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
{formatErrorRate(row.errorRate)}
</MonoText>
),
key: 'cpuUsage',
header: 'CPU',
render: (_val, row) => {
const cpu = (row as AgentInstance & { cpuUsage?: number }).cpuUsage;
return (
<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',
@@ -351,18 +527,18 @@ export default function AgentHealth() {
/>
<StatCard
label="Applications"
value={String(groups.length)}
value={String(allGroups.length)}
accent="running"
detail={
<span className={styles.breakdown}>
<span className={styles.bpLive}>
<StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
<StatusDot variant="live" /> {allGroups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
</span>
<span className={styles.bpStale}>
<StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
<StatusDot variant="stale" /> {allGroups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
</span>
<span className={styles.bpDead}>
<StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical
<StatusDot variant="dead" /> {allGroups.filter((g) => g.deadCount > 0).length} critical
</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 */}
<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>
{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>
{group.maxCpu >= 0 && <span><strong>{(group.maxCpu * 100).toFixed(0)}%</strong> cpu</span>}
<span>
Single point of failure &mdash;{' '}
{group.deadCount === group.instances.length
? 'no redundancy'
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
<StatusDot
variant={
appHealth(group) === 'success'
? 'live'
: appHealth(group) === 'warning'
? 'stale'
: 'dead'
}
/>
</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>
}
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) => (
<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 */}
<div className={styles.bottomRow}>