Compare commits
20 Commits
78396a2796
...
11ad769f59
| Author | SHA1 | Date | |
|---|---|---|---|
| 11ad769f59 | |||
|
|
3a9f3f41de | ||
|
|
e84822f211 | ||
|
|
e346b9bb9d | ||
|
|
f4811359e1 | ||
|
|
e5b1171833 | ||
|
|
d27a288128 | ||
|
|
7825aae274 | ||
|
|
4b264b3308 | ||
|
|
b57fe875f3 | ||
|
|
911ba591a9 | ||
|
|
9d1cf7577a | ||
|
|
1fa897fbb5 | ||
|
|
9f7951aa2b | ||
|
|
61df59853b | ||
|
|
5229e08b27 | ||
|
|
d0c2fd1ac3 | ||
|
|
5c94881608 | ||
|
|
23d24487d1 | ||
|
|
bf289aa1b1 |
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.0–1.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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
764
docs/superpowers/plans/2026-04-16-runtime-compact-view.md
Normal file
764
docs/superpowers/plans/2026-04-16-runtime-compact-view.md
Normal 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}>⚠</span>
|
||||||
|
<span>
|
||||||
|
Single point of failure —{' '}
|
||||||
|
{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}>⚠</span>
|
||||||
|
<span>
|
||||||
|
Single point of failure —{' '}
|
||||||
|
{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"
|
||||||
|
```
|
||||||
@@ -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).
|
||||||
2
ui/src/api/schema.d.ts
vendored
2
ui/src/api/schema.d.ts
vendored
@@ -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 */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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}>⚠</span>
|
|
||||||
<span>
|
<span>
|
||||||
Single point of failure —{' '}
|
<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}>⚠</span>
|
||||||
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
|
<span>
|
||||||
onRowClick={handleInstanceClick}
|
Single point of failure —{' '}
|
||||||
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}>⚠</span>
|
||||||
|
<span>
|
||||||
|
Single point of failure —{' '}
|
||||||
|
{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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user