Remove all OpenSearch code, dependencies, configuration, deployment manifests, and CI/CD references. Replace the OpenSearch admin page with a ClickHouse admin page showing cluster status, table sizes, performance metrics, and indexer pipeline stats. - Delete 11 OpenSearch Java files (config, search impl, admin controller, DTOs, tests) - Delete 3 OpenSearch frontend files (admin page, CSS, query hooks) - Delete deploy/opensearch.yaml K8s manifest - Remove opensearch Maven dependencies from pom.xml - Remove opensearch config from application.yml, Dockerfile, docker-compose - Remove opensearch from CI workflow (secrets, deploy, cleanup steps) - Simplify ThresholdConfig (remove OpenSearch thresholds, database-only) - Change default search backend from opensearch to clickhouse - Add ClickHouseAdminController with /status, /tables, /performance, /pipeline - Add ClickHouseAdminPage with StatCards, pipeline ProgressBar, tables DataTable - Update CLAUDE.md, HOWTO.md, and source comments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useParams, Link } from 'react-router';
|
|
import { RefreshCw, ChevronRight } from 'lucide-react';
|
|
import {
|
|
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
|
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
|
LogViewer, ButtonGroup, useGlobalFilters,
|
|
} from '@cameleer/design-system';
|
|
import type { FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
|
|
import styles from './AgentInstance.module.css';
|
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
|
import { useApplicationLogs } from '../../api/queries/logs';
|
|
import { useStatsTimeseries } from '../../api/queries/executions';
|
|
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
|
|
|
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)' },
|
|
];
|
|
|
|
function mapLogLevel(level: string): LogEntry['level'] {
|
|
switch (level?.toUpperCase()) {
|
|
case 'ERROR': return 'error';
|
|
case 'WARN': case 'WARNING': return 'warn';
|
|
case 'DEBUG': return 'debug';
|
|
case 'TRACE': return 'trace';
|
|
default: return 'info';
|
|
}
|
|
}
|
|
|
|
export default function AgentInstance() {
|
|
const { appId, instanceId } = useParams();
|
|
const { timeRange } = useGlobalFilters();
|
|
const [logSearch, setLogSearch] = useState('');
|
|
const [logLevels, setLogLevels] = useState<Set<string>>(new Set());
|
|
const [logSortAsc, setLogSortAsc] = useState(false);
|
|
const [eventSortAsc, setEventSortAsc] = useState(false);
|
|
const [logRefreshTo, setLogRefreshTo] = useState<string | undefined>();
|
|
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
|
|
const timeFrom = timeRange.start.toISOString();
|
|
const timeTo = timeRange.end.toISOString();
|
|
|
|
const { data: agents, isLoading } = useAgents(undefined, appId);
|
|
const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo);
|
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
|
|
|
const agent = useMemo(
|
|
() => (agents || []).find((a: any) => a.id === instanceId) as any,
|
|
[agents, instanceId],
|
|
);
|
|
|
|
// Stat card metrics (latest 1 bucket)
|
|
const { data: latestMetrics } = useAgentMetrics(
|
|
agent?.id || null,
|
|
['jvm.cpu.process', 'jvm.memory.heap.used', 'jvm.memory.heap.max'],
|
|
1,
|
|
);
|
|
const cpuPct = latestMetrics?.metrics?.['jvm.cpu.process']?.[0]?.value;
|
|
const heapUsed = latestMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
|
|
const heapMax = latestMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
|
|
const memPct = heapMax ? (heapUsed! / heapMax) * 100 : undefined;
|
|
|
|
// Chart metrics (60 buckets)
|
|
const { data: jvmMetrics } = useAgentMetrics(
|
|
agent?.id || null,
|
|
['jvm.cpu.process', 'jvm.memory.heap.used', 'jvm.memory.heap.max', 'jvm.threads.count', 'jvm.gc.time'],
|
|
60,
|
|
);
|
|
|
|
const chartData = useMemo(
|
|
() =>
|
|
(timeseries?.buckets || []).map((b: any) => ({
|
|
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
throughput: b.totalCount,
|
|
latency: b.avgDurationMs,
|
|
errors: b.failedCount,
|
|
})),
|
|
[timeseries],
|
|
);
|
|
|
|
const feedEvents = useMemo<FeedEvent[]>(() => {
|
|
const mapped = (events || [])
|
|
.filter((e: any) => !instanceId || e.instanceId === instanceId)
|
|
.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.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`,
|
|
timestamp: new Date(e.timestamp),
|
|
}));
|
|
return eventSortAsc ? mapped.toReversed() : mapped;
|
|
}, [events, instanceId, eventSortAsc]);
|
|
|
|
// JVM chart series helpers
|
|
const cpuSeries = useMemo(() => {
|
|
const pts = jvmMetrics?.metrics?.['jvm.cpu.process'];
|
|
if (!pts?.length) return null;
|
|
return [{ label: 'CPU %', data: pts.map((p: any, i: number) => ({ x: i, y: p.value * 100 })) }];
|
|
}, [jvmMetrics]);
|
|
|
|
const heapSeries = useMemo(() => {
|
|
const pts = jvmMetrics?.metrics?.['jvm.memory.heap.used'];
|
|
if (!pts?.length) return null;
|
|
return [{ label: 'Heap MB', data: pts.map((p: any, i: number) => ({ x: i, y: p.value / (1024 * 1024) })) }];
|
|
}, [jvmMetrics]);
|
|
|
|
const threadSeries = useMemo(() => {
|
|
const pts = jvmMetrics?.metrics?.['jvm.threads.count'];
|
|
if (!pts?.length) return null;
|
|
return [{ label: 'Threads', data: pts.map((p: any, i: number) => ({ x: i, y: p.value })) }];
|
|
}, [jvmMetrics]);
|
|
|
|
const gcSeries = useMemo(() => {
|
|
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
|
|
if (!pts?.length) return null;
|
|
return [{ label: 'GC ms', data: pts.map((p: any) => ({ x: String(p.time ?? ''), y: p.value })) }];
|
|
}, [jvmMetrics]);
|
|
|
|
const throughputSeries = useMemo(
|
|
() =>
|
|
chartData.length
|
|
? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]
|
|
: null,
|
|
[chartData],
|
|
);
|
|
|
|
const errorSeries = useMemo(
|
|
() =>
|
|
chartData.length
|
|
? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }]
|
|
: null,
|
|
[chartData],
|
|
);
|
|
|
|
// Application logs
|
|
const { data: rawLogs } = useApplicationLogs(appId, instanceId, { toOverride: logRefreshTo });
|
|
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 searchLower = logSearch.toLowerCase();
|
|
const filteredLogs = logEntries
|
|
.filter((l) => logLevels.size === 0 || logLevels.has(l.level))
|
|
.filter((l) => !searchLower || l.message.toLowerCase().includes(searchLower));
|
|
|
|
if (isLoading) return <Spinner size="lg" />;
|
|
|
|
const statusVariant =
|
|
agent?.status === 'LIVE' ? 'live' : agent?.status === 'STALE' ? 'stale' : 'dead';
|
|
const statusColor: 'success' | 'warning' | 'error' =
|
|
agent?.status === 'LIVE' ? 'success' : agent?.status === 'STALE' ? 'warning' : 'error';
|
|
const cpuDisplay = cpuPct != null ? (cpuPct * 100).toFixed(0) : null;
|
|
const heapUsedMB = heapUsed != null ? (heapUsed / (1024 * 1024)).toFixed(0) : null;
|
|
const heapMaxMB = heapMax != null ? (heapMax / (1024 * 1024)).toFixed(0) : null;
|
|
|
|
return (
|
|
<div className={styles.content}>
|
|
{/* Stat strip — 5 columns */}
|
|
<div className={styles.statStrip}>
|
|
<StatCard
|
|
label="CPU"
|
|
value={cpuDisplay != null ? `${cpuDisplay}%` : '\u2014'}
|
|
accent={
|
|
cpuDisplay != null
|
|
? Number(cpuDisplay) > 85
|
|
? 'error'
|
|
: Number(cpuDisplay) > 70
|
|
? 'warning'
|
|
: 'success'
|
|
: undefined
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="Memory"
|
|
value={memPct != null ? `${memPct.toFixed(0)}%` : '\u2014'}
|
|
accent={
|
|
memPct != null
|
|
? memPct > 85
|
|
? 'error'
|
|
: memPct > 70
|
|
? 'warning'
|
|
: 'success'
|
|
: undefined
|
|
}
|
|
detail={
|
|
heapUsedMB != null && heapMaxMB != null
|
|
? `${heapUsedMB} MB / ${heapMaxMB} MB`
|
|
: undefined
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="Throughput"
|
|
value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}
|
|
accent="amber"
|
|
detail="msg/s"
|
|
/>
|
|
<StatCard
|
|
label="Errors"
|
|
value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '\u2014'}
|
|
accent={agent?.errorRate > 0 ? 'error' : 'success'}
|
|
/>
|
|
<StatCard
|
|
label="Uptime"
|
|
value={formatUptime(agent?.uptimeSeconds)}
|
|
accent="running"
|
|
detail={
|
|
agent?.registeredAt
|
|
? `since ${new Date(agent.registeredAt).toLocaleDateString()}`
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Scope trail + badges */}
|
|
{agent && (
|
|
<>
|
|
<div className={styles.scopeTrail}>
|
|
<Link to="/agents" className={styles.scopeLink}>
|
|
All Agents
|
|
</Link>
|
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
|
<Link to={`/agents/${appId}`} className={styles.scopeLink}>
|
|
{appId}
|
|
</Link>
|
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
|
<span className={styles.scopeCurrent}>{agent.name}</span>
|
|
<StatusDot variant={statusVariant} />
|
|
<Badge label={agent.status} color={statusColor} />
|
|
{agent.version && <Badge label={agent.version} variant="outlined" color="auto" />}
|
|
<Badge
|
|
label={`${agent.activeRoutes ?? (agent.routeIds?.length ?? 0)}/${agent.totalRoutes ?? (agent.routeIds?.length ?? 0)} routes`}
|
|
color={
|
|
(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Process info card */}
|
|
<div className={styles.processCard}>
|
|
<SectionHeader>Process Information</SectionHeader>
|
|
<div className={styles.processGrid}>
|
|
{agent.capabilities?.jvmVersion && (
|
|
<>
|
|
<span className={styles.processLabel}>JVM</span>
|
|
<MonoText size="xs">{agent.capabilities.jvmVersion}</MonoText>
|
|
</>
|
|
)}
|
|
{agent.capabilities?.camelVersion && (
|
|
<>
|
|
<span className={styles.processLabel}>Camel</span>
|
|
<MonoText size="xs">{agent.capabilities.camelVersion}</MonoText>
|
|
</>
|
|
)}
|
|
{agent.capabilities?.springBootVersion && (
|
|
<>
|
|
<span className={styles.processLabel}>Spring Boot</span>
|
|
<MonoText size="xs">{agent.capabilities.springBootVersion}</MonoText>
|
|
</>
|
|
)}
|
|
<span className={styles.processLabel}>Started</span>
|
|
<MonoText size="xs">
|
|
{agent.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '\u2014'}
|
|
</MonoText>
|
|
{agent.capabilities && (
|
|
<>
|
|
<span className={styles.processLabel}>Capabilities</span>
|
|
<span className={styles.capTags}>
|
|
{Object.entries(agent.capabilities)
|
|
.filter(([, v]) => typeof v === 'boolean' && v)
|
|
.map(([k]) => (
|
|
<Badge key={k} label={k} variant="outlined" />
|
|
))}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Routes */}
|
|
{(agent.routeIds?.length ?? 0) > 0 && (
|
|
<>
|
|
<SectionHeader>Routes</SectionHeader>
|
|
<div className={styles.routeBadges}>
|
|
{(agent.routeIds || []).map((r: string) => (
|
|
<Badge key={r} label={r} color="auto" />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Charts grid — 3x2 */}
|
|
<div className={styles.chartsGrid}>
|
|
<div className={styles.chartCard}>
|
|
<div className={styles.chartHeader}>
|
|
<span className={styles.chartTitle}>CPU Usage</span>
|
|
<span className={styles.chartMeta}>
|
|
{cpuDisplay != null ? `${cpuDisplay}% current` : ''}
|
|
</span>
|
|
</div>
|
|
{cpuSeries ? (
|
|
<AreaChart
|
|
series={cpuSeries}
|
|
height={160}
|
|
yLabel="%"
|
|
threshold={{ value: 85, label: 'Alert' }}
|
|
/>
|
|
) : (
|
|
<EmptyState title="No data" description="No CPU metrics available" />
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.chartCard}>
|
|
<div className={styles.chartHeader}>
|
|
<span className={styles.chartTitle}>Memory (Heap)</span>
|
|
<span className={styles.chartMeta}>
|
|
{heapUsedMB != null && heapMaxMB != null
|
|
? `${heapUsedMB} MB / ${heapMaxMB} MB`
|
|
: ''}
|
|
</span>
|
|
</div>
|
|
{heapSeries ? (
|
|
<AreaChart series={heapSeries} height={160} yLabel="MB" />
|
|
) : (
|
|
<EmptyState title="No data" description="No heap metrics available" />
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.chartCard}>
|
|
<div className={styles.chartHeader}>
|
|
<span className={styles.chartTitle}>Throughput</span>
|
|
<span className={styles.chartMeta}>
|
|
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
|
|
</span>
|
|
</div>
|
|
{throughputSeries ? (
|
|
<LineChart series={throughputSeries} height={160} yLabel="msg/s" />
|
|
) : (
|
|
<EmptyState title="No data" description="No throughput data in range" />
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.chartCard}>
|
|
<div className={styles.chartHeader}>
|
|
<span className={styles.chartTitle}>Error Rate</span>
|
|
<span className={styles.chartMeta}>
|
|
{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}
|
|
</span>
|
|
</div>
|
|
{errorSeries ? (
|
|
<LineChart series={errorSeries} height={160} yLabel="err/h" />
|
|
) : (
|
|
<EmptyState title="No data" description="No error data in range" />
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.chartCard}>
|
|
<div className={styles.chartHeader}>
|
|
<span className={styles.chartTitle}>Thread Count</span>
|
|
<span className={styles.chartMeta}>
|
|
{threadSeries
|
|
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
|
|
: ''}
|
|
</span>
|
|
</div>
|
|
{threadSeries ? (
|
|
<LineChart series={threadSeries} height={160} yLabel="threads" />
|
|
) : (
|
|
<EmptyState title="No data" description="No thread metrics available" />
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.chartCard}>
|
|
<div className={styles.chartHeader}>
|
|
<span className={styles.chartTitle}>GC Pauses</span>
|
|
<span className={styles.chartMeta} />
|
|
</div>
|
|
{gcSeries ? (
|
|
<BarChart series={gcSeries} height={160} yLabel="ms" />
|
|
) : (
|
|
<EmptyState title="No data" description="No GC metrics available" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log + Timeline side by side */}
|
|
<div className={styles.bottomRow}>
|
|
<div className={styles.logCard}>
|
|
<div className={styles.logHeader}>
|
|
<SectionHeader>Application Log</SectionHeader>
|
|
<div className={styles.headerActions}>
|
|
<span className={styles.chartMeta}>{logEntries.length} entries</span>
|
|
<button className={styles.sortBtn} onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}>
|
|
{logSortAsc ? '\u2191' : '\u2193'}
|
|
</button>
|
|
<button className={styles.refreshBtn} onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh">
|
|
<RefreshCw size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className={styles.logToolbar}>
|
|
<div className={styles.logSearchWrap}>
|
|
<input
|
|
type="text"
|
|
className={styles.logSearchInput}
|
|
placeholder="Search logs\u2026"
|
|
value={logSearch}
|
|
onChange={(e) => setLogSearch(e.target.value)}
|
|
aria-label="Search logs"
|
|
/>
|
|
{logSearch && (
|
|
<button
|
|
type="button"
|
|
className={styles.logSearchClear}
|
|
onClick={() => setLogSearch('')}
|
|
aria-label="Clear search"
|
|
>
|
|
×
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
|
|
{logLevels.size > 0 && (
|
|
<button className={styles.logClearFilters} onClick={() => setLogLevels(new Set())}>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
{filteredLogs.length > 0 ? (
|
|
<LogViewer entries={filteredLogs} maxHeight={360} />
|
|
) : (
|
|
<div className={styles.logEmpty}>
|
|
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.timelineCard}>
|
|
<div className={styles.timelineHeader}>
|
|
<span className={styles.chartTitle}>Timeline</span>
|
|
<div className={styles.headerActions}>
|
|
<span className={styles.chartMeta}>{feedEvents.length} events</span>
|
|
<button className={styles.sortBtn} onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
|
|
{eventSortAsc ? '\u2191' : '\u2193'}
|
|
</button>
|
|
<button className={styles.refreshBtn} onClick={() => setEventRefreshTo(new Date().toISOString())} title="Refresh">
|
|
<RefreshCw size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{feedEvents.length > 0 ? (
|
|
<EventFeed events={feedEvents} maxItems={50} />
|
|
) : (
|
|
<div className={styles.logEmpty}>No events in the selected time range.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatUptime(seconds?: number): string {
|
|
if (!seconds) return '\u2014';
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
if (days > 0) return `${days}d ${hours}h`;
|
|
if (hours > 0) return `${hours}h ${mins}m`;
|
|
return `${mins}m`;
|
|
}
|