feat: replace UI with design system example pages wired to real API
Some checks failed
CI / build (push) Successful in 1m18s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 55s
CI / deploy-feature (push) Has been cancelled
CI / deploy (push) Has been cancelled

Migrate all page components from the @cameleer/design-system v0.0.3
example UI, replacing mock data with real backend API hooks. This brings
richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline,
DateRangePicker, expandable rows) while preserving all existing API
integration, auth, and routing infrastructure.

Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail,
AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles).
Also enhanced LayoutShell CommandPalette with real search data from catalog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 16:42:16 +01:00
parent dafd7adb00
commit 81f85aa82d
23 changed files with 4439 additions and 2542 deletions

View File

@@ -1,3 +1,13 @@
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
/* Stat strip */
.statStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -5,13 +15,66 @@
margin-bottom: 16px;
}
/* Stat breakdown with colored dots */
.breakdown {
display: flex;
gap: 8px;
font-size: 11px;
font-family: var(--font-mono);
}
.bpLive { color: var(--success); display: inline-flex; align-items: center; gap: 3px; }
.bpStale { color: var(--warning); display: inline-flex; align-items: center; gap: 3px; }
.bpDead { color: var(--error); display: inline-flex; align-items: center; gap: 3px; }
.routesSuccess { color: var(--success); }
.routesWarning { color: var(--warning); }
.routesError { color: var(--error); }
/* Scope breadcrumb trail */
.scopeTrail {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
gap: 6px;
margin-bottom: 12px;
font-size: 12px;
}
.scopeLink {
color: var(--amber);
text-decoration: none;
font-weight: 500;
}
.scopeLink:hover {
text-decoration: underline;
}
.scopeSep {
color: var(--text-muted);
font-size: 10px;
}
.scopeCurrent {
color: var(--text-primary);
font-weight: 600;
font-family: var(--font-mono);
}
/* Section header */
.sectionTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.sectionMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Group cards grid */
.groupGrid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -19,115 +82,131 @@
margin-bottom: 20px;
}
/* GroupCard meta strip */
.groupGridSingle {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
margin-bottom: 20px;
}
/* Group meta row */
.groupMeta {
display: flex;
gap: 16px;
align-items: center;
font-size: 12px;
gap: 16px;
font-size: 11px;
color: var(--text-muted);
}
.groupMeta strong {
color: var(--text-primary);
}
/* Instance table */
.instanceTable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.instanceTable thead tr {
border-bottom: 1px solid var(--border-subtle);
}
.instanceTable thead th {
padding: 6px 8px;
text-align: left;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-secondary);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.thStatus {
width: 24px;
/* Alert banner in group footer */
.alertBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--error-bg);
font-size: 11px;
color: var(--error);
font-weight: 500;
}
.tdStatus {
width: 24px;
padding: 0 4px 0 8px;
}
.instanceRow {
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.instanceRow:last-child {
border-bottom: none;
}
.instanceRow:hover {
background: var(--bg-hover);
}
.instanceRow td {
padding: 7px 8px;
vertical-align: middle;
}
.instanceRowActive {
background: var(--bg-selected, var(--bg-hover));
.alertIcon {
font-size: 14px;
flex-shrink: 0;
}
/* Instance fields */
.instanceName {
font-weight: 600;
color: var(--text-primary);
}
.instanceMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
}
.instanceError {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
}
.instanceHeartbeatDead {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
white-space: nowrap;
}
.instanceHeartbeatStale {
font-size: 11px;
color: var(--warning);
font-family: var(--font-mono);
font-weight: 600;
white-space: nowrap;
}
.instanceLink {
.instanceHeartbeatDead {
color: var(--error);
font-weight: 600;
white-space: nowrap;
}
/* Detail panel content */
.detailContent {
display: flex;
flex-direction: column;
gap: 12px;
}
.detailRow {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-family: var(--font-body);
padding: 4px 0;
border-bottom: 1px solid var(--border-subtle);
}
.detailLabel {
color: var(--text-muted);
text-decoration: none;
font-size: 14px;
padding: 4px;
margin-left: auto;
font-weight: 500;
}
.instanceLink:hover {
color: var(--text-primary);
.detailProgress {
display: flex;
align-items: center;
gap: 8px;
width: 140px;
}
.chartPanel {
display: flex;
flex-direction: column;
gap: 6px;
}
.chartTitle {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.emptyChart {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background: var(--bg-surface-raised);
border: 1px dashed var(--border-subtle);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text-muted);
}
/* Event card (timeline panel) */
.eventCard {
margin-top: 20px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
@@ -144,136 +223,4 @@
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
/* DetailPanel: Overview tab */
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
padding: 4px 0;
}
.overviewRow {
display: flex;
align-items: center;
gap: 8px;
}
.detailList {
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 0;
}
.detailRow {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 12px;
}
.detailRow:last-child {
border-bottom: none;
}
.detailRow dt {
color: var(--text-muted);
font-weight: 500;
}
.detailRow dd {
margin: 0;
color: var(--text-primary);
text-align: right;
}
.metricsSection {
display: flex;
flex-direction: column;
gap: 6px;
}
.metricLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* DetailPanel: Performance tab */
.performanceContent {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
}
.chartSection {
display: flex;
flex-direction: column;
gap: 6px;
}
.chartLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.emptyChart {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background: var(--bg-surface-raised);
border: 1px dashed var(--border-subtle);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text-muted);
}
/* Status breakdown in stat card */
.statusBreakdown {
display: flex;
gap: 8px;
font-size: 11px;
}
.statusLive { color: var(--success); }
.statusStale { color: var(--warning); }
.statusDead { color: var(--error); }
/* Scope trail */
.scopeLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
/* DetailPanel override */
.detailPanelOverride {
position: fixed;
top: 0;
right: 0;
height: 100vh;
z-index: 100;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
}
.panelDivider {
border-top: 1px solid var(--border-subtle);
margin: 16px 0;
}

View File

@@ -1,17 +1,31 @@
import { useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useState, useMemo } from 'react';
import { useParams, Link } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText,
GroupCard, EventFeed, Alert,
DetailPanel, ProgressBar, LineChart,
StatCard, StatusDot, Badge, MonoText, ProgressBar,
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
} from '@cameleer/design-system';
import type { Column, FeedEvent } from '@cameleer/design-system';
import styles from './AgentHealth.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import type { AgentInstance } from '../../api/types';
// ── Helpers ──────────────────────────────────────────────────────────────────
function timeAgo(iso?: string): string {
if (!iso) return '\u2014';
const diff = Date.now() - new Date(iso).getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function formatUptime(seconds?: number): string {
if (!seconds) return '';
if (!seconds) return '\u2014';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
@@ -20,18 +34,65 @@ function formatUptime(seconds?: number): string {
return `${mins}m`;
}
function formatRelativeTime(iso?: string): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
function formatErrorRate(rate?: number): string {
if (rate == null) return '\u2014';
return `${(rate * 100).toFixed(1)}%`;
}
function AgentOverviewContent({ agent }: { agent: any }) {
type NormStatus = 'live' | 'stale' | 'dead';
function normalizeStatus(status: string): NormStatus {
return status.toLowerCase() as NormStatus;
}
function statusColor(s: NormStatus): 'success' | 'warning' | 'error' {
if (s === 'live') return 'success';
if (s === 'stale') return 'warning';
return 'error';
}
// ── Data grouping ────────────────────────────────────────────────────────────
interface AppGroup {
appId: string;
instances: AgentInstance[];
liveCount: number;
staleCount: number;
deadCount: number;
totalTps: number;
totalActiveRoutes: number;
totalRoutes: number;
}
function groupByApp(agentList: AgentInstance[]): AppGroup[] {
const map = new Map<string, AgentInstance[]>();
for (const a of agentList) {
const app = a.application;
const list = map.get(app) ?? [];
list.push(a);
map.set(app, list);
}
return Array.from(map.entries()).map(([appId, instances]) => ({
appId,
instances,
liveCount: instances.filter((i) => normalizeStatus(i.status) === 'live').length,
staleCount: instances.filter((i) => normalizeStatus(i.status) === 'stale').length,
deadCount: instances.filter((i) => normalizeStatus(i.status) === 'dead').length,
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),
}));
}
function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
if (group.deadCount > 0) return 'error';
if (group.staleCount > 0) return 'warning';
return 'success';
}
// ── Detail sub-components ────────────────────────────────────────────────────
function AgentOverviewContent({ agent }: { agent: AgentInstance }) {
const { data: memMetrics } = useAgentMetrics(
agent.id,
['jvm.memory.heap.used', 'jvm.memory.heap.max'],
@@ -43,93 +104,81 @@ function AgentOverviewContent({ agent }: { agent: any }) {
const heapUsed = memMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
const heapPercent = heapUsed != null && heapMax != null && heapMax > 0
? Math.round((heapUsed / heapMax) * 100)
: undefined;
const heapPercent =
heapUsed != null && heapMax != null && heapMax > 0
? Math.round((heapUsed / heapMax) * 100)
: undefined;
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
const statusVariant: 'live' | 'stale' | 'dead' =
agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead';
const statusColor: 'success' | 'warning' | 'error' =
agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error';
const ns = normalizeStatus(agent.status);
return (
<div className={styles.overviewContent}>
<div className={styles.overviewRow}>
<StatusDot variant={statusVariant} />
<Badge label={agent.status} color={statusColor} />
<div className={styles.detailContent}>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Status</span>
<Badge label={agent.status} color={statusColor(ns)} variant="filled" />
</div>
<dl className={styles.detailList}>
<div className={styles.detailRow}>
<dt>Application</dt>
<dd><MonoText>{agent.application ?? '—'}</MonoText></dd>
</div>
<div className={styles.detailRow}>
<dt>Version</dt>
<dd><MonoText>{agent.version ?? '—'}</MonoText></dd>
</div>
<div className={styles.detailRow}>
<dt>Uptime</dt>
<dd>{formatUptime(agent.uptimeSeconds)}</dd>
</div>
<div className={styles.detailRow}>
<dt>Last Heartbeat</dt>
<dd>{formatRelativeTime(agent.lastHeartbeat)}</dd>
</div>
<div className={styles.detailRow}>
<dt>TPS</dt>
<dd>{agent.tps != null ? (agent.tps as number).toFixed(2) : '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Error Rate</dt>
<dd>{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Routes</dt>
<dd>{agent.activeRoutes ?? '—'} active / {agent.totalRoutes ?? '—'} total</dd>
</div>
</dl>
<div className={styles.metricsSection}>
<div className={styles.metricLabel}>
Heap Memory{heapUsed != null && heapMax != null
? `${Math.round(heapUsed / 1024 / 1024)}MB / ${Math.round(heapMax / 1024 / 1024)}MB`
: ''}
</div>
<ProgressBar
value={heapPercent}
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
indeterminate={heapPercent == null}
size="sm"
/>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Application</span>
<MonoText size="xs">{agent.application}</MonoText>
</div>
<div className={styles.metricsSection}>
<div className={styles.metricLabel}>
CPU Usage{cpuPercent != null ? `${cpuPercent}%` : ''}
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Uptime</span>
<MonoText size="xs">{formatUptime(agent.uptimeSeconds)}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Last Seen</span>
<MonoText size="xs">{timeAgo(agent.lastHeartbeat)}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Throughput</span>
<MonoText size="xs">{agent.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Errors</span>
<MonoText size="xs" className={agent.errorRate ? styles.instanceError : undefined}>
{formatErrorRate(agent.errorRate)}
</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Routes</span>
<span>{agent.activeRoutes ?? 0}/{agent.totalRoutes ?? 0} active</span>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Heap Memory</span>
<div className={styles.detailProgress}>
<ProgressBar
value={heapPercent}
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
indeterminate={heapPercent == null}
size="sm"
/>
<MonoText size="xs">{heapPercent != null ? `${heapPercent}%` : '\u2014'}</MonoText>
</div>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>CPU</span>
<div className={styles.detailProgress}>
<ProgressBar
value={cpuPercent}
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
indeterminate={cpuPercent == null}
size="sm"
/>
<MonoText size="xs">{cpuPercent != null ? `${cpuPercent}%` : '\u2014'}</MonoText>
</div>
<ProgressBar
value={cpuPercent}
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
indeterminate={cpuPercent == null}
size="sm"
/>
</div>
</div>
);
}
function AgentPerformanceContent({ agent }: { agent: any }) {
function AgentPerformanceContent({ agent }: { agent: AgentInstance }) {
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
const tpsSeries = useMemo(() => {
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
return [{
label: 'TPS',
data: raw.map((p) => ({ x: new Date(p.time), y: p.value })),
}];
return [{ label: 'TPS', data: raw.map((p) => ({ x: new Date(p.time), y: p.value })) }];
}, [tpsMetrics]);
const errSeries = useMemo(() => {
@@ -137,24 +186,24 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
return [{
label: 'Error Rate',
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
color: 'var(--error)',
}];
}, [errMetrics]);
return (
<div className={styles.performanceContent}>
<div className={styles.chartSection}>
<div className={styles.chartLabel}>Throughput (TPS)</div>
<div className={styles.detailContent}>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
{tpsSeries[0].data.length > 0 ? (
<LineChart series={tpsSeries} yLabel="req/s" height={160} />
<LineChart series={tpsSeries} height={160} yLabel="msg/s" />
) : (
<div className={styles.emptyChart}>No data available</div>
)}
</div>
<div className={styles.chartSection}>
<div className={styles.chartLabel}>Error Rate (%)</div>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Error Rate (%)</div>
{errSeries[0].data.length > 0 ? (
<LineChart series={errSeries} yLabel="%" height={160} />
<LineChart series={errSeries} height={160} yLabel="%" />
) : (
<div className={styles.emptyChart}>No data available</div>
)}
@@ -163,197 +212,308 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
);
}
// ── AgentHealth page ─────────────────────────────────────────────────────────
export default function AgentHealth() {
const { appId } = useParams();
const navigate = useNavigate();
const { data: agents } = useAgents(undefined, appId);
const { data: catalog } = useRouteCatalog();
const { data: events } = useAgentEvents(appId);
const [selectedAgent, setSelectedAgent] = useState<any>(null);
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const agentsByApp = useMemo(() => {
const map: Record<string, any[]> = {};
(agents || []).forEach((a: any) => {
const g = a.application;
if (!map[g]) map[g] = [];
map[g].push(a);
});
return map;
}, [agents]);
const agentList = agents ?? [];
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length;
const uniqueApps = new Set((agents || []).map((a: any) => a.application)).size;
const activeRoutes = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.activeRoutes || 0), 0);
const totalTps = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.tps || 0), 0);
const groups = useMemo(() => groupByApp(agentList), [agentList]);
const feedEvents = useMemo(() =>
(events || []).map((e: any) => ({
id: String(e.id),
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
: e.eventType === 'WENT_STALE' ? 'warning' as const
: e.eventType === 'RECOVERED' ? 'success' as const
: 'running' as const,
message: `${e.agentId}: ${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
// Aggregate stats
const totalInstances = agentList.length;
const liveCount = agentList.filter((a) => normalizeStatus(a.status) === 'live').length;
const staleCount = agentList.filter((a) => normalizeStatus(a.status) === 'stale').length;
const deadCount = agentList.filter((a) => normalizeStatus(a.status) === 'dead').length;
const totalTps = agentList.reduce((s, a) => s + (a.tps ?? 0), 0);
const totalActiveRoutes = agentList.reduce((s, a) => s + (a.activeRoutes ?? 0), 0);
const totalRoutes = agentList.reduce((s, a) => s + (a.totalRoutes ?? 0), 0);
// Map events to FeedEvent
const feedEvents: FeedEvent[] = useMemo(
() =>
(events ?? []).map((e: { id: number; agentId: string; eventType: string; detail: string; timestamp: string }) => ({
id: String(e.id),
severity:
e.eventType === 'WENT_DEAD'
? ('error' as const)
: e.eventType === 'WENT_STALE'
? ('warning' as const)
: e.eventType === 'RECOVERED'
? ('success' as const)
: ('running' as const),
message: `${e.agentId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
[events],
);
const apps = appId ? { [appId]: agentsByApp[appId] || [] } : agentsByApp;
// Column definitions for the instance DataTable
const instanceColumns: Column<AgentInstance>[] = useMemo(
() => [
{
key: 'status',
header: '',
width: '12px',
render: (_val, row) => <StatusDot variant={normalizeStatus(row.status)} />,
},
{
key: 'name',
header: 'Instance',
render: (_val, row) => (
<MonoText size="sm" className={styles.instanceName}>{row.name ?? row.id}</MonoText>
),
},
{
key: 'state',
header: 'State',
render: (_val, row) => {
const ns = normalizeStatus(row.status);
return <Badge label={row.status} color={statusColor(ns)} variant="filled" />;
},
},
{
key: 'uptime',
header: 'Uptime',
render: (_val, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{formatUptime(row.uptimeSeconds)}</MonoText>
),
},
{
key: 'tps',
header: 'TPS',
render: (_val, row) => (
<MonoText size="xs" className={styles.instanceMeta}>
{row.tps != null ? `${row.tps.toFixed(1)}/s` : '\u2014'}
</MonoText>
),
},
{
key: 'errorRate',
header: 'Errors',
render: (_val, row) => (
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
{formatErrorRate(row.errorRate)}
</MonoText>
),
},
{
key: 'lastHeartbeat',
header: 'Heartbeat',
render: (_val, row) => {
const ns = normalizeStatus(row.status);
return (
<MonoText
size="xs"
className={
ns === 'dead'
? styles.instanceHeartbeatDead
: ns === 'stale'
? styles.instanceHeartbeatStale
: styles.instanceMeta
}
>
{timeAgo(row.lastHeartbeat)}
</MonoText>
);
},
},
],
[],
);
function handleInstanceClick(inst: AgentInstance) {
setSelectedInstance(inst);
setPanelOpen(true);
}
// Detail panel tabs
const detailTabs = selectedInstance
? [
{
label: 'Overview',
value: 'overview',
content: <AgentOverviewContent agent={selectedInstance} />,
},
{
label: 'Performance',
value: 'performance',
content: <AgentPerformanceContent agent={selectedInstance} />,
},
]
: [];
const isFullWidth = !!appId;
return (
<div>
<div className={styles.content}>
{/* Stat strip */}
<div className={styles.statStrip}>
<StatCard
label="Total Agents"
value={(agents || []).length}
value={String(totalInstances)}
accent={deadCount > 0 ? 'warning' : 'amber'}
detail={
<span className={styles.statusBreakdown}>
<span className={styles.statusLive}>{liveCount} live</span>
<span className={styles.statusStale}>{staleCount} stale</span>
<span className={styles.statusDead}>{deadCount} dead</span>
<span className={styles.breakdown}>
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
</span>
}
/>
<StatCard label="Applications" value={uniqueApps} />
<StatCard label="Active Routes" value={activeRoutes} />
<StatCard label="Total TPS" value={totalTps.toFixed(1)} detail="msg/s" />
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} />
</div>
<div className={styles.scopeTrail}>
<span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span>
</div>
<div className={styles.groupGrid}>
{Object.entries(apps).map(([group, groupAgents]) => {
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0);
const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0);
const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0);
const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length;
return (
<GroupCard
key={group}
title={group}
headerRight={
<Badge
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`}
color={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
}
variant="filled"
/>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{groupTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{groupActiveRoutes}</strong>/{groupTotalRoutes} routes</span>
</div>
}
accent={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
<StatCard
label="Applications"
value={String(groups.length)}
accent="running"
detail={
<span className={styles.breakdown}>
<span className={styles.bpLive}>
<StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
</span>
<span className={styles.bpStale}>
<StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
</span>
<span className={styles.bpDead}>
<StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical
</span>
</span>
}
/>
<StatCard
label="Active Routes"
value={
<span
className={
styles[
totalActiveRoutes === 0
? 'routesError'
: totalActiveRoutes < totalRoutes
? 'routesWarning'
: 'routesSuccess'
]
}
>
{deadInGroup.length > 0 && (
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
)}
<table className={styles.instanceTable}>
<thead>
<tr>
<th className={styles.thStatus} />
<th>Instance</th>
<th>State</th>
<th>Uptime</th>
<th>TPS</th>
<th>Errors</th>
<th>Heartbeat</th>
</tr>
</thead>
<tbody>
{(groupAgents || []).map((agent: any) => (
<tr
key={agent.id}
className={[
styles.instanceRow,
selectedAgent?.id === agent.id ? styles.instanceRowActive : '',
].filter(Boolean).join(' ')}
onClick={() => {
setSelectedAgent(agent);
navigate(`/agents/${group}/${agent.id}`);
}}
>
<td className={styles.tdStatus}>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
</td>
<td>
<MonoText size="sm" className={styles.instanceName}>{agent.name ?? agent.id}</MonoText>
</td>
<td>
<Badge
label={agent.status}
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
variant="filled"
/>
</td>
<td>
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
</td>
<td>
<span className={styles.instanceMeta}>{agent.tps != null ? `${(agent.tps as number).toFixed(1)}/s` : '—'}</span>
</td>
<td>
<span className={agent.errorRate != null ? styles.instanceError : styles.instanceMeta}>
{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}
</span>
</td>
<td>
<span className={
agent.status === 'DEAD' ? styles.instanceHeartbeatDead
: agent.status === 'STALE' ? styles.instanceHeartbeatStale
: styles.instanceMeta
}>
{formatRelativeTime(agent.lastHeartbeat)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</GroupCard>
);
})}
{totalActiveRoutes}/{totalRoutes}
</span>
}
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
/>
<StatCard
label="Total TPS"
value={totalTps.toFixed(1)}
accent="amber"
detail="msg/s"
/>
<StatCard
label="Dead"
value={String(deadCount)}
accent={deadCount > 0 ? 'error' : 'success'}
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
/>
</div>
{/* Scope trail + badges */}
<div className={styles.scopeTrail}>
{appId && (
<>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeCurrent}>{appId}</span>
</>
)}
<Badge
label={`${liveCount}/${totalInstances} live`}
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
variant="filled"
/>
</div>
{/* Group cards grid */}
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
{groups.map((group) => (
<GroupCard
key={group.appId}
title={group.appId}
accent={appHealth(group)}
headerRight={
<Badge
label={`${group.liveCount}/${group.instances.length} LIVE`}
color={appHealth(group)}
variant="filled"
/>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
<span>
<StatusDot
variant={
appHealth(group) === 'success'
? 'live'
: appHealth(group) === 'warning'
? 'stale'
: 'dead'
}
/>
</span>
</div>
}
footer={
group.deadCount > 0 ? (
<div className={styles.alertBanner}>
<span className={styles.alertIcon}>&#9888;</span>
<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>
columns={instanceColumns}
data={group.instances}
onRowClick={handleInstanceClick}
selectedId={panelOpen ? selectedInstance?.id : undefined}
pageSize={50}
flush
/>
</GroupCard>
))}
</div>
{/* EventFeed */}
{feedEvents.length > 0 && (
<div className={styles.eventCard}>
<div className={styles.eventCardHeader}>
<span>Timeline</span>
<Badge label={`${feedEvents.length} events`} variant="outlined" />
<span className={styles.sectionTitle}>Timeline</span>
<span className={styles.sectionMeta}>{feedEvents.length} events</span>
</div>
<EventFeed events={feedEvents} maxItems={100} />
</div>
)}
{selectedAgent && (
{/* Detail panel */}
{selectedInstance && (
<DetailPanel
key={selectedAgent.id}
open={true}
title={selectedAgent.name ?? selectedAgent.id}
onClose={() => setSelectedAgent(null)}
className={styles.detailPanelOverride}
>
<AgentOverviewContent agent={selectedAgent} />
<div className={styles.panelDivider} />
<AgentPerformanceContent agent={selectedAgent} />
</DetailPanel>
open={panelOpen}
onClose={() => {
setPanelOpen(false);
setSelectedInstance(null);
}}
title={selectedInstance.name ?? selectedInstance.id}
tabs={detailTabs}
/>
)}
</div>
);