Files
cameleer-server/ui/src/pages/AgentHealth/AgentHealth.tsx
hsiegeln 07dbfb1391 fix(ui): log header counter reflects visible (filtered) count
When a text search is active, show 'X of Y entries' rather than the
loaded total, so the number matches what's on screen.
2026-04-17 13:19:51 +02:00

1020 lines
42 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 } from '../../api/queries/agents';
import { useInfiniteApplicationLogs } from '../../api/queries/logs';
import { useInfiniteAgentEvents } from '../../api/queries/agents';
import { InfiniteScrollArea } from '../../components/InfiniteScrollArea';
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);
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
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 [isTimelineAtTop, setIsTimelineAtTop] = useState(true);
const timelineScrollRef = useRef<HTMLDivElement | null>(null);
const eventStream = useInfiniteAgentEvents({ appId, isAtTop: isTimelineAtTop });
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 [logSources, setLogSources] = useState<Set<string>>(new Set());
const [logSortAsc, setLogSortAsc] = useState(false);
const [isLogAtTop, setIsLogAtTop] = useState(true);
const logScrollRef = useRef<HTMLDivElement | null>(null);
const logStream = useInfiniteApplicationLogs({
application: appId,
sources: [...logSources],
levels: [...logLevels],
sort: logSortAsc ? 'asc' : 'desc',
isAtTop: isLogAtTop,
});
const logEntries = useMemo<LogEntry[]>(() => {
return logStream.items.map((l) => ({
timestamp: l.timestamp ?? '',
level: mapLogLevel(l.level),
message: l.message ?? '',
source: l.source ?? undefined,
}));
}, [logStream.items]);
const logSearchLower = logSearch.toLowerCase();
const filteredLogs = logSearchLower
? logEntries.filter((l) => l.message.toLowerCase().includes(logSearchLower))
: logEntries;
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 = eventStream.items.map((e) => ({
id: `${e.timestamp}:${e.instanceId}:${e.eventType}`,
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;
}, [eventStream.items, 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'} &mdash; {(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"
>
&times;
</button>
)}
</div>
<div className={styles.sortGroup}>
{([['status', 'Status'], ['name', 'Name'], ['tps', 'TPS'], ['cpu', 'CPU'], ['heartbeat', 'Heartbeat']] as const).map(([key, label]) => (
<button
key={key}
className={`${styles.sortBtn} ${appSortKey === key ? styles.sortBtnActive : ''}`}
onClick={() => cycleSort(key)}
title={`Sort by ${label}`}
>
{label}
{appSortKey === key && (
appSortAsc ? <ArrowUp size={10} /> : <ArrowDown size={10} />
)}
</button>
))}
</div>
</div>
)}
{/* Group cards grid */}
{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}>&#9888;</span>
<span>
Single point of failure &mdash;{' '}
{group.deadCount === group.instances.length
? 'no redundancy'
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
</span>
</div>
) : undefined
}
>
<DataTable<AgentInstance & { id: string }>
columns={instanceColumns as Column<AgentInstance & { id: string }>[]}
data={group.instances.map(i => ({ ...i, id: i.instanceId }))}
onRowClick={handleInstanceClick}
pageSize={50}
flush
/>
</GroupCard>
))}
</div>
) : (
<div className={styles.compactGrid}>
{groups.map((group) => (
<div key={group.appId} className={styles.compactGridCell}>
<CompactAppCard
group={group}
onExpand={() => animateToggle(group.appId)}
onNavigate={() => navigate(`/runtime/${group.appId}`)}
/>
{expandedApps.has(group.appId) && (
<>
<div className={styles.overlayBackdrop} onClick={() => animateToggle(group.appId)} />
<div
ref={(el) => {
if (!el) return;
// Constrain overlay within viewport
const rect = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
if (rect.right > vw - 16) {
el.style.left = 'auto';
el.style.right = '0';
}
if (rect.bottom > document.documentElement.clientHeight) {
const overflow = rect.bottom - document.documentElement.clientHeight + 16;
el.style.maxHeight = `${rect.height - overflow}px`;
el.style.overflowY = 'auto';
}
}}
className={`${styles.compactGridExpanded} ${styles.expandWrapper} ${
animatingApps.get(group.appId) === 'expanding'
? styles.expandWrapperCollapsed
: animatingApps.get(group.appId) === 'collapsing'
? styles.expandWrapperCollapsed
: styles.expandWrapperExpanded
}`}
>
<GroupCard
title={group.appId}
accent={appHealth(group)}
headerRight={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Badge
label={`${group.liveCount}/${group.instances.length} LIVE`}
color={appHealth(group)}
variant="filled"
/>
<button
className={styles.collapseBtn}
onClick={(e) => { e.stopPropagation(); animateToggle(group.appId); }}
title="Collapse"
>
<ChevronDown size={14} />
</button>
</div>
}
meta={
<div className={styles.groupMeta}>
<span><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}>&#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}>
<div className={logStyles.logCard}>
<div className={logStyles.logHeader}>
<SectionHeader>Application Log</SectionHeader>
<div className={logStyles.headerActions}>
<span className={styles.sectionMeta}>
{filteredLogs.length === logStream.items.length
? `${filteredLogs.length} entries`
: `${filteredLogs.length} of ${logStream.items.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={() => {
logStream.refresh();
logScrollRef.current?.scrollTo({ top: 0 });
}} 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"
>
&times;
</button>
)}
</div>
<ButtonGroup
items={LOG_SOURCE_ITEMS}
value={logSources}
onChange={setLogSources}
/>
{logSources.size > 0 && (
<Button variant="ghost" size="sm" onClick={() => setLogSources(new Set())}>
Clear
</Button>
)}
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
{logLevels.size > 0 && (
<Button variant="ghost" size="sm" onClick={() => setLogLevels(new Set())}>
Clear
</Button>
)}
</div>
<InfiniteScrollArea
scrollRef={logScrollRef}
onEndReached={logStream.fetchNextPage}
onTopVisibilityChange={setIsLogAtTop}
isFetchingNextPage={logStream.isFetchingNextPage}
hasNextPage={logStream.hasNextPage}
isLoading={logStream.isLoading}
hasItems={logStream.items.length > 0}
maxHeight={360}
>
{filteredLogs.length > 0 ? (
<LogViewer entries={filteredLogs} />
) : (
<div className={logStyles.logEmpty}>
{logSearch || logLevels.size > 0 || logSources.size > 0
? 'No matching log entries'
: logStream.isLoading ? 'Loading logs\u2026' : 'No log entries available'}
</div>
)}
</InfiniteScrollArea>
</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}>{eventStream.items.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={() => {
eventStream.refresh();
timelineScrollRef.current?.scrollTo({ top: 0 });
}} title="Refresh">
<RefreshCw size={14} />
</Button>
</div>
</div>
<InfiniteScrollArea
scrollRef={timelineScrollRef}
onEndReached={eventStream.fetchNextPage}
onTopVisibilityChange={setIsTimelineAtTop}
isFetchingNextPage={eventStream.isFetchingNextPage}
hasNextPage={eventStream.hasNextPage}
isLoading={eventStream.isLoading}
hasItems={eventStream.items.length > 0}
maxHeight={360}
>
{feedEvents.length > 0 ? (
<EventFeed events={feedEvents} />
) : (
<div className={logStyles.logEmpty}>
{eventStream.isLoading ? 'Loading events\u2026' : 'No events in the selected time range.'}
</div>
)}
</InfiniteScrollArea>
</div>
</div>
</div>
);
}