Compare commits

10 Commits

Author SHA1 Message Date
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
6 changed files with 382 additions and 80 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

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

View File

@@ -13,7 +13,7 @@
/* Stat strip */
.statStrip {
display: grid;
grid-template-columns: repeat(5, 1fr) auto;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 16px;
}
@@ -99,6 +99,7 @@
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
margin-bottom: 20px;
position: relative;
}
/* Group meta row */
@@ -291,6 +292,12 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.compactCardName:hover {
text-decoration: underline;
color: var(--running);
}
.compactCardChevron {
@@ -309,6 +316,16 @@
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);
}
@@ -317,25 +334,53 @@
color: var(--card-accent);
}
/* Expanded card inside compact grid */
/* Wrapper for each compact grid cell — anchor for overlay */
.compactGridCell {
position: relative;
}
/* Expanded card overlay — floats from the clicked card */
.compactGridExpanded {
grid-column: span 2;
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: max-height 200ms ease, opacity 150ms ease;
transition: opacity 200ms ease, transform 200ms ease;
}
.expandWrapperCollapsed {
max-height: 0;
opacity: 0;
transform: scaleY(0.95);
transform-origin: top;
}
.expandWrapperExpanded {
max-height: 1000px;
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: 8px;
margin-bottom: 12px;
}
/* View mode toggle */

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router';
import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown } from 'lucide-react';
import {
@@ -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() {
@@ -135,6 +195,57 @@ export default function AgentHealth() {
});
}, []);
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>>({});
@@ -194,7 +305,7 @@ export default function AgentHealth() {
const agentList = agents ?? [];
const groups = useMemo(() => groupByApp(agentList), [agentList]);
const groups = useMemo(() => groupByApp(agentList).sort((a, b) => a.appId.localeCompare(b.appId)), [agentList]);
// Aggregate stats
const totalInstances = agentList.length;
@@ -420,22 +531,6 @@ export default function AgentHealth() {
accent={deadCount > 0 ? 'error' : 'success'}
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
/>
<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>
{/* Application config bar */}
@@ -550,60 +645,184 @@ export default function AgentHealth() {
{/* 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.viewToolbar}>
<div className={styles.viewToggle}>
<button
className={`${styles.viewToggleBtn} ${styles.viewToggleBtnActive}`}
onClick={() => toggleViewMode('compact')}
title="Compact view"
>
<LayoutGrid size={14} />
</button>
<button
className={styles.viewToggleBtn}
onClick={() => toggleViewMode('expanded')}
title="Expanded view"
>
<List size={14} />
</button>
</div>
</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}>