Use Activity, Cpu, and HeartPulse icons instead of "tps", "cpu", and "ago" text in compact and expanded app cards. Bump design-system to v0.1.55 for sidebar footer alignment fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
968 lines
40 KiB
TypeScript
968 lines
40 KiB
TypeScript
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router';
|
|
import { ExternalLink, RefreshCw, Pencil, LayoutGrid, List, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Search, Cpu, HeartPulse, Activity } from 'lucide-react';
|
|
import {
|
|
StatCard, StatusDot, Badge, MonoText,
|
|
GroupCard, DataTable, EventFeed,
|
|
LogViewer, ButtonGroup, SectionHeader, Toggle, Select, Button, useToast,
|
|
Alert, ConfirmDialog,
|
|
} from '@cameleer/design-system';
|
|
import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
|
|
import styles from './AgentHealth.module.css';
|
|
import sectionStyles from '../../styles/section-card.module.css';
|
|
import logStyles from '../../styles/log-panel.module.css';
|
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
|
import { useApplicationLogs } from '../../api/queries/logs';
|
|
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
|
import { useCatalog, useDismissApp } from '../../api/queries/catalog';
|
|
import { useIsAdmin } from '../../auth/auth-store';
|
|
import { useEnvironmentStore } from '../../api/environment-store';
|
|
import type { ConfigUpdateResponse } from '../../api/queries/commands';
|
|
import type { AgentInstance } from '../../api/types';
|
|
import { timeAgo } from '../../utils/format-utils';
|
|
import { formatUptime, mapLogLevel, eventSeverity, eventIcon } from '../../utils/agent-utils';
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function formatErrorRate(rate?: number): string {
|
|
if (rate == null) return '\u2014';
|
|
return `${(rate * 100).toFixed(1)}%`;
|
|
}
|
|
|
|
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;
|
|
maxCpu: number;
|
|
}
|
|
|
|
function groupByApp(agentList: AgentInstance[]): AppGroup[] {
|
|
const map = new Map<string, AgentInstance[]>();
|
|
for (const a of agentList) {
|
|
const app = a.applicationId;
|
|
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),
|
|
maxCpu: Math.max(...instances.map((i) => (i as AgentInstance & { cpuUsage?: number }).cpuUsage ?? -1)),
|
|
}));
|
|
}
|
|
|
|
function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
|
|
if (group.deadCount > 0) return 'error';
|
|
if (group.staleCount > 0) return 'warning';
|
|
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[] = [
|
|
{ value: 'error', label: 'Error', color: 'var(--error)' },
|
|
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
|
|
{ value: 'info', label: 'Info', color: 'var(--success)' },
|
|
{ value: 'debug', label: 'Debug', color: 'var(--running)' },
|
|
{ value: 'trace', label: 'Trace', color: 'var(--text-muted)' },
|
|
];
|
|
|
|
const LOG_SOURCE_ITEMS: ButtonGroupItem[] = [
|
|
{ value: 'app', label: 'App' },
|
|
{ value: 'agent', label: 'Agent' },
|
|
{ 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}>
|
|
<Activity size={10} /> {group.totalTps.toFixed(1)}/s
|
|
</span>
|
|
{group.maxCpu >= 0 && (
|
|
<span className={styles.compactCardCpu}>
|
|
<Cpu size={10} /> {(group.maxCpu * 100).toFixed(0)}%
|
|
</span>
|
|
)}
|
|
<span className={isHealthy ? styles.compactCardHeartbeat : styles.compactCardHeartbeatWarn}>
|
|
<HeartPulse size={10} /> {heartbeat ? timeAgo(heartbeat, true) : '\u2014'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
|
|
|
export default function AgentHealth() {
|
|
const { appId } = useParams();
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
|
const { data: agents } = useAgents(undefined, appId, selectedEnv);
|
|
const { data: appConfig } = useApplicationConfig(appId);
|
|
const updateConfig = useUpdateApplicationConfig();
|
|
|
|
const isAdmin = useIsAdmin();
|
|
const selectedEnvForCatalog = useEnvironmentStore((s) => s.environment);
|
|
const { data: catalogApps } = useCatalog(selectedEnvForCatalog);
|
|
const dismissApp = useDismissApp();
|
|
const catalogEntry = catalogApps?.find((a) => a.slug === appId);
|
|
|
|
const [confirmDismissOpen, setConfirmDismissOpen] = useState(false);
|
|
const [viewMode, setViewMode] = useState<'compact' | 'expanded'>(() => {
|
|
const saved = localStorage.getItem('cameleer:runtime:viewMode');
|
|
return saved === 'expanded' ? 'expanded' : 'compact';
|
|
});
|
|
const [expandedApps, setExpandedApps] = useState<Set<string>>(new Set());
|
|
|
|
const toggleViewMode = useCallback((mode: 'compact' | 'expanded') => {
|
|
setViewMode(mode);
|
|
setExpandedApps(new Set());
|
|
localStorage.setItem('cameleer:runtime:viewMode', mode);
|
|
}, []);
|
|
|
|
const toggleAppExpanded = useCallback((appId: string) => {
|
|
setExpandedApps((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(appId)) next.delete(appId);
|
|
else next.add(appId);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const [animatingApps, setAnimatingApps] = useState<Map<string, 'expanding' | 'collapsing'>>(new Map());
|
|
const animationTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
|
|
const animateToggle = useCallback((appIdToToggle: string) => {
|
|
// Clear any existing timer for this app
|
|
const existing = animationTimers.current.get(appIdToToggle);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
const isCurrentlyExpanded = expandedApps.has(appIdToToggle);
|
|
|
|
if (isCurrentlyExpanded) {
|
|
// Collapsing: start animation, then remove from expandedApps after transition
|
|
setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'collapsing'));
|
|
const timer = setTimeout(() => {
|
|
setExpandedApps((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(appIdToToggle);
|
|
return next;
|
|
});
|
|
setAnimatingApps((prev) => {
|
|
const next = new Map(prev);
|
|
next.delete(appIdToToggle);
|
|
return next;
|
|
});
|
|
animationTimers.current.delete(appIdToToggle);
|
|
}, 200);
|
|
animationTimers.current.set(appIdToToggle, timer);
|
|
} else {
|
|
// Expanding: add to expandedApps immediately, animate in
|
|
setExpandedApps((prev) => new Set(prev).add(appIdToToggle));
|
|
setAnimatingApps((prev) => new Map(prev).set(appIdToToggle, 'expanding'));
|
|
// Use requestAnimationFrame to ensure the collapsed state renders first
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
setAnimatingApps((prev) => {
|
|
const next = new Map(prev);
|
|
next.delete(appIdToToggle);
|
|
return next;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}, [expandedApps]);
|
|
|
|
// Cleanup timers on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
animationTimers.current.forEach((timer) => clearTimeout(timer));
|
|
};
|
|
}, []);
|
|
|
|
const [configEditing, setConfigEditing] = useState(false);
|
|
const [configDraft, setConfigDraft] = useState<Record<string, string | boolean>>({});
|
|
|
|
const startConfigEdit = useCallback(() => {
|
|
if (!appConfig) return;
|
|
setConfigDraft({
|
|
applicationLogLevel: appConfig.applicationLogLevel ?? 'INFO',
|
|
agentLogLevel: appConfig.agentLogLevel ?? 'INFO',
|
|
engineLevel: appConfig.engineLevel ?? 'REGULAR',
|
|
payloadCaptureMode: appConfig.payloadCaptureMode ?? 'BOTH',
|
|
metricsEnabled: appConfig.metricsEnabled,
|
|
});
|
|
setConfigEditing(true);
|
|
}, [appConfig]);
|
|
|
|
const saveConfigEdit = useCallback(() => {
|
|
if (!appConfig) return;
|
|
const updated = { ...appConfig, ...configDraft };
|
|
updateConfig.mutate({ config: updated, environment: selectedEnv }, {
|
|
onSuccess: (saved: ConfigUpdateResponse) => {
|
|
setConfigEditing(false);
|
|
setConfigDraft({});
|
|
if (saved.pushResult.success) {
|
|
toast({ title: 'Config updated', description: `${appId} (v${saved.config.version}) — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents`, variant: 'success' });
|
|
} else {
|
|
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
|
|
toast({ title: 'Config updated — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
|
|
}
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Config update failed', variant: 'error', duration: 86_400_000 });
|
|
},
|
|
});
|
|
}, [appConfig, configDraft, updateConfig, toast, appId]);
|
|
const [eventSortAsc, setEventSortAsc] = useState(false);
|
|
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
|
|
const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo, selectedEnv);
|
|
|
|
const [appFilter, setAppFilter] = useState('');
|
|
type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat';
|
|
const [appSortKey, setAppSortKey] = useState<AppSortKey>('name');
|
|
const [appSortAsc, setAppSortAsc] = useState(true);
|
|
|
|
const cycleSort = useCallback((key: AppSortKey) => {
|
|
if (appSortKey === key) {
|
|
setAppSortAsc((prev) => !prev);
|
|
} else {
|
|
setAppSortKey(key);
|
|
setAppSortAsc(key === 'name'); // name defaults asc, others desc
|
|
}
|
|
}, [appSortKey]);
|
|
|
|
const [logSearch, setLogSearch] = useState('');
|
|
const [logLevels, setLogLevels] = useState<Set<string>>(new Set());
|
|
const [logSource, setLogSource] = useState<string>(''); // '' = all, 'app', 'agent'
|
|
const [logSortAsc, setLogSortAsc] = useState(false);
|
|
const [logRefreshTo, setLogRefreshTo] = useState<string | undefined>();
|
|
const { data: rawLogs } = useApplicationLogs(appId, undefined, { toOverride: logRefreshTo, source: logSource || undefined });
|
|
const logEntries = useMemo<LogEntry[]>(() => {
|
|
const mapped = (rawLogs || []).map((l) => ({
|
|
timestamp: l.timestamp ?? '',
|
|
level: mapLogLevel(l.level),
|
|
message: l.message ?? '',
|
|
}));
|
|
return logSortAsc ? mapped.toReversed() : mapped;
|
|
}, [rawLogs, logSortAsc]);
|
|
const logSearchLower = logSearch.toLowerCase();
|
|
const filteredLogs = logEntries
|
|
.filter((l) => logLevels.size === 0 || logLevels.has(l.level))
|
|
.filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower));
|
|
|
|
const agentList = agents ?? [];
|
|
|
|
const allGroups = useMemo(() => groupByApp(agentList), [agentList]);
|
|
|
|
const sortedGroups = useMemo(() => {
|
|
const healthPriority = (g: AppGroup) => g.deadCount > 0 ? 0 : g.staleCount > 0 ? 1 : 2;
|
|
const nameCmp = (a: AppGroup, b: AppGroup) => a.appId.localeCompare(b.appId);
|
|
|
|
const sorted = [...allGroups].sort((a, b) => {
|
|
let cmp = 0;
|
|
switch (appSortKey) {
|
|
case 'status': cmp = healthPriority(a) - healthPriority(b); break;
|
|
case 'name': cmp = a.appId.localeCompare(b.appId); break;
|
|
case 'tps': cmp = a.totalTps - b.totalTps; break;
|
|
case 'cpu': cmp = a.maxCpu - b.maxCpu; break;
|
|
case 'heartbeat': {
|
|
const ha = latestHeartbeat(a) ?? '';
|
|
const hb = latestHeartbeat(b) ?? '';
|
|
cmp = ha < hb ? -1 : ha > hb ? 1 : 0;
|
|
break;
|
|
}
|
|
}
|
|
if (!appSortAsc) cmp = -cmp;
|
|
return cmp !== 0 ? cmp : nameCmp(a, b);
|
|
});
|
|
return sorted;
|
|
}, [allGroups, appSortKey, appSortAsc]);
|
|
|
|
const appFilterLower = appFilter.toLowerCase();
|
|
const groups = appFilterLower ? sortedGroups.filter((g) => g.appId.toLowerCase().includes(appFilterLower)) : sortedGroups;
|
|
|
|
// Aggregate stats
|
|
const totalInstances = agentList.length;
|
|
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(() => {
|
|
const mapped = (events ?? []).map((e: { id: number; instanceId: string; eventType: string; detail: string; timestamp: string }) => ({
|
|
id: String(e.id),
|
|
severity: eventSeverity(e.eventType),
|
|
icon: eventIcon(e.eventType),
|
|
message: `${e.instanceId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
|
|
timestamp: new Date(e.timestamp),
|
|
}));
|
|
return eventSortAsc ? mapped.toReversed() : mapped;
|
|
}, [events, eventSortAsc]);
|
|
|
|
// 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: '_inspect',
|
|
header: '',
|
|
width: '36px',
|
|
render: (_val, row) => (
|
|
<button
|
|
className={styles.inspectLink}
|
|
title="Open instance page"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigate(`/agents/${row.applicationId}/${row.instanceId}`);
|
|
}}
|
|
>
|
|
<ExternalLink size={14} />
|
|
</button>
|
|
),
|
|
},
|
|
{
|
|
key: 'name',
|
|
header: 'Instance',
|
|
render: (_val, row) => (
|
|
<MonoText size="sm" className={styles.instanceName}>{row.displayName ?? row.instanceId}</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: 'cpuUsage',
|
|
header: 'CPU',
|
|
render: (_val, row) => {
|
|
const cpu = (row as AgentInstance & { cpuUsage?: number }).cpuUsage;
|
|
return (
|
|
<MonoText size="xs" className={cpu != null && cpu > 0.8 ? styles.instanceError : styles.instanceMeta}>
|
|
{cpu != null && cpu >= 0 ? `${(cpu * 100).toFixed(0)}%` : '\u2014'}
|
|
</MonoText>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'lastHeartbeat',
|
|
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) {
|
|
navigate(`/runtime/${inst.applicationId}/${inst.instanceId}`);
|
|
}
|
|
|
|
const isFullWidth = !!appId;
|
|
|
|
return (
|
|
<div className={styles.content}>
|
|
{/* No agents warning + dismiss — shown when app-scoped, no agents, admin */}
|
|
{appId && agentList.length === 0 && isAdmin && (
|
|
<>
|
|
<Alert variant="warning" title="No agents connected" className={styles.dismissAlert}>
|
|
<span>
|
|
{catalogEntry?.managed ? 'Managed app' : 'Discovered app'} — {(catalogEntry?.exchangeCount ?? 0).toLocaleString()} exchanges recorded.
|
|
{' '}You can dismiss this application to remove it and all associated data.
|
|
</span>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Button variant="danger" size="sm" disabled={dismissApp.isPending}
|
|
onClick={() => setConfirmDismissOpen(true)}>
|
|
{dismissApp.isPending ? 'Dismissing\u2026' : 'Dismiss Application'}
|
|
</Button>
|
|
</div>
|
|
</Alert>
|
|
<ConfirmDialog
|
|
open={confirmDismissOpen}
|
|
onClose={() => setConfirmDismissOpen(false)}
|
|
onConfirm={() => {
|
|
setConfirmDismissOpen(false);
|
|
dismissApp.mutate(appId, {
|
|
onSuccess: () => {
|
|
toast({ title: 'Application dismissed', description: `${appId} and all associated data have been deleted`, variant: 'success' });
|
|
navigate('/runtime');
|
|
},
|
|
onError: (err) => {
|
|
toast({ title: 'Dismiss failed', description: err.message, variant: 'error', duration: 86_400_000 });
|
|
},
|
|
});
|
|
}}
|
|
title="Dismiss Application"
|
|
message={`This will permanently delete "${appId}" and all associated data (${(catalogEntry?.exchangeCount ?? 0).toLocaleString()} exchanges, logs, diagrams). This action cannot be undone.`}
|
|
confirmText={appId}
|
|
confirmLabel="Dismiss"
|
|
variant="danger"
|
|
loading={dismissApp.isPending}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Stat strip */}
|
|
<div className={styles.statStrip}>
|
|
<StatCard
|
|
label="Total Agents"
|
|
value={String(totalInstances)}
|
|
accent={deadCount > 0 ? 'warning' : 'amber'}
|
|
detail={
|
|
<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={String(allGroups.length)}
|
|
accent="running"
|
|
detail={
|
|
<span className={styles.breakdown}>
|
|
<span className={styles.bpLive}>
|
|
<StatusDot variant="live" /> {allGroups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
|
|
</span>
|
|
<span className={styles.bpStale}>
|
|
<StatusDot variant="stale" /> {allGroups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
|
|
</span>
|
|
<span className={styles.bpDead}>
|
|
<StatusDot variant="dead" /> {allGroups.filter((g) => g.deadCount > 0).length} critical
|
|
</span>
|
|
</span>
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="Active Routes"
|
|
value={
|
|
<span
|
|
className={
|
|
styles[
|
|
totalActiveRoutes === 0
|
|
? 'routesError'
|
|
: totalActiveRoutes < totalRoutes
|
|
? 'routesWarning'
|
|
: 'routesSuccess'
|
|
]
|
|
}
|
|
>
|
|
{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>
|
|
|
|
{/* Application config bar */}
|
|
{appId && appConfig && (
|
|
<div className={`${sectionStyles.section} ${styles.configBar}`}>
|
|
{configEditing ? (
|
|
<>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>App Log Level</span>
|
|
<Select value={String(configDraft.applicationLogLevel ?? 'INFO')}
|
|
onChange={(e) => setConfigDraft(d => ({ ...d, applicationLogLevel: e.target.value }))}
|
|
options={[
|
|
{ value: 'ERROR', label: 'ERROR' },
|
|
{ value: 'WARN', label: 'WARN' },
|
|
{ value: 'INFO', label: 'INFO' },
|
|
{ value: 'DEBUG', label: 'DEBUG' },
|
|
{ value: 'TRACE', label: 'TRACE' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>Agent Log Level</span>
|
|
<Select value={String(configDraft.agentLogLevel ?? 'INFO')}
|
|
onChange={(e) => setConfigDraft(d => ({ ...d, agentLogLevel: e.target.value }))}
|
|
options={[
|
|
{ value: 'ERROR', label: 'ERROR' },
|
|
{ value: 'WARN', label: 'WARN' },
|
|
{ value: 'INFO', label: 'INFO' },
|
|
{ value: 'DEBUG', label: 'DEBUG' },
|
|
{ value: 'TRACE', label: 'TRACE' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>Engine Level</span>
|
|
<Select value={String(configDraft.engineLevel ?? 'REGULAR')}
|
|
onChange={(e) => setConfigDraft(d => ({ ...d, engineLevel: e.target.value }))}
|
|
options={[
|
|
{ value: 'NONE', label: 'None' },
|
|
{ value: 'MINIMAL', label: 'Minimal' },
|
|
{ value: 'REGULAR', label: 'Regular' },
|
|
{ value: 'COMPLETE', label: 'Complete' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>Payload Capture</span>
|
|
<Select value={String(configDraft.payloadCaptureMode ?? 'BOTH')}
|
|
onChange={(e) => setConfigDraft(d => ({ ...d, payloadCaptureMode: e.target.value }))}
|
|
options={[
|
|
{ value: 'NONE', label: 'None' },
|
|
{ value: 'INPUT', label: 'Input' },
|
|
{ value: 'OUTPUT', label: 'Output' },
|
|
{ value: 'BOTH', label: 'Both' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>Metrics</span>
|
|
<Toggle checked={Boolean(configDraft.metricsEnabled)}
|
|
onChange={(e) => setConfigDraft(d => ({ ...d, metricsEnabled: (e.target as HTMLInputElement).checked }))} />
|
|
</div>
|
|
<div className={styles.configActions}>
|
|
<Button variant="primary" size="sm" onClick={saveConfigEdit} disabled={updateConfig.isPending}>Save</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => { setConfigEditing(false); setConfigDraft({}); }}>Cancel</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>App Log Level</span>
|
|
<Badge label={appConfig.applicationLogLevel ?? 'INFO'} color={
|
|
(appConfig.applicationLogLevel ?? 'INFO') === 'ERROR' ? 'error'
|
|
: (appConfig.applicationLogLevel ?? 'INFO') === 'WARN' ? 'warning'
|
|
: (appConfig.applicationLogLevel ?? 'INFO') === 'DEBUG' ? 'running'
|
|
: (appConfig.applicationLogLevel ?? 'INFO') === 'TRACE' ? 'auto' : 'success'
|
|
} variant="filled" />
|
|
</div>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>Agent Log Level</span>
|
|
<Badge label={appConfig.agentLogLevel ?? 'INFO'} color={
|
|
(appConfig.agentLogLevel ?? 'INFO') === 'ERROR' ? 'error'
|
|
: (appConfig.agentLogLevel ?? 'INFO') === 'WARN' ? 'warning'
|
|
: (appConfig.agentLogLevel ?? 'INFO') === 'DEBUG' ? 'running'
|
|
: (appConfig.agentLogLevel ?? 'INFO') === 'TRACE' ? 'auto' : 'success'
|
|
} variant="filled" />
|
|
</div>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>Engine Level</span>
|
|
<Badge label={appConfig.engineLevel ?? 'REGULAR'} color={
|
|
(appConfig.engineLevel ?? 'REGULAR') === 'NONE' ? 'error'
|
|
: (appConfig.engineLevel ?? 'REGULAR') === 'MINIMAL' ? 'warning'
|
|
: (appConfig.engineLevel ?? 'REGULAR') === 'COMPLETE' ? 'running' : 'success'
|
|
} variant="filled" />
|
|
</div>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>Payload Capture</span>
|
|
<Badge label={appConfig.payloadCaptureMode ?? 'BOTH'} color={
|
|
(appConfig.payloadCaptureMode ?? 'BOTH') === 'BOTH' ? 'running'
|
|
: (appConfig.payloadCaptureMode ?? 'BOTH') === 'NONE' ? 'auto' : 'warning'
|
|
} variant="filled" />
|
|
</div>
|
|
<div className={styles.configField}>
|
|
<span className={styles.configLabel}>Metrics</span>
|
|
<Badge label={appConfig.metricsEnabled ? 'On' : 'Off'} color={appConfig.metricsEnabled ? 'success' : 'error'} variant="filled" />
|
|
</div>
|
|
<Button variant="ghost" size="sm" title="Edit config" onClick={startConfigEdit}><Pencil size={14} /></Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* 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 */}
|
|
{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><Activity size={12} /> <strong>{group.totalTps.toFixed(1)}</strong>/s</span>
|
|
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
|
{group.maxCpu >= 0 && <span><Cpu size={12} /> <strong>{(group.maxCpu * 100).toFixed(0)}%</strong></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) => (
|
|
<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><Activity size={12} /> <strong>{group.totalTps.toFixed(1)}</strong>/s</span>
|
|
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
|
{group.maxCpu >= 0 && <span><Cpu size={12} /> <strong>{(group.maxCpu * 100).toFixed(0)}%</strong></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 */}
|
|
<div className={styles.bottomRow}>
|
|
<div className={logStyles.logCard}>
|
|
<div className={logStyles.logHeader}>
|
|
<SectionHeader>Application Log</SectionHeader>
|
|
<div className={logStyles.headerActions}>
|
|
<span className={styles.sectionMeta}>{logEntries.length} entries</span>
|
|
<Button variant="ghost" size="sm" onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}>
|
|
{logSortAsc ? '\u2191' : '\u2193'}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh">
|
|
<RefreshCw size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className={logStyles.logToolbar}>
|
|
<div className={logStyles.logSearchWrap}>
|
|
<input
|
|
type="text"
|
|
className={logStyles.logSearchInput}
|
|
placeholder="Search logs\u2026"
|
|
value={logSearch}
|
|
onChange={(e) => setLogSearch(e.target.value)}
|
|
aria-label="Search logs"
|
|
/>
|
|
{logSearch && (
|
|
<button
|
|
type="button"
|
|
className={logStyles.logSearchClear}
|
|
onClick={() => setLogSearch('')}
|
|
aria-label="Clear search"
|
|
>
|
|
×
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ButtonGroup
|
|
items={LOG_SOURCE_ITEMS}
|
|
value={logSource ? new Set([logSource]) : new Set()}
|
|
onChange={(v) => setLogSource(v.size === 0 ? '' : [...v][0])}
|
|
/>
|
|
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
|
|
{logLevels.size > 0 && (
|
|
<Button variant="ghost" size="sm" onClick={() => setLogLevels(new Set())}>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{filteredLogs.length > 0 ? (
|
|
<LogViewer entries={filteredLogs} maxHeight={360} />
|
|
) : (
|
|
<div className={logStyles.logEmpty}>
|
|
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`${sectionStyles.section} ${styles.eventCard}`}>
|
|
<div className={styles.eventCardHeader}>
|
|
<span className={styles.sectionTitle}>Timeline</span>
|
|
<div className={logStyles.headerActions}>
|
|
<span className={styles.sectionMeta}>{feedEvents.length} events</span>
|
|
<Button variant="ghost" size="sm" onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
|
|
{eventSortAsc ? '\u2191' : '\u2193'}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setEventRefreshTo(new Date().toISOString())} title="Refresh">
|
|
<RefreshCw size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{feedEvents.length > 0 ? (
|
|
<EventFeed events={feedEvents} maxItems={100} />
|
|
) : (
|
|
<div className={logStyles.logEmpty}>No events in the selected time range.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|