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,12 @@
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
/* Stat strip — 5 columns matching /agents */
.statStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -5,18 +14,67 @@
margin-bottom: 16px;
}
.agentHeader {
/* Scope trail — matches /agents */
.scopeTrail {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0;
gap: 6px;
margin-bottom: 12px;
font-size: 12px;
}
.agentHeader h2 {
font-size: 18px;
.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);
}
/* Process info card */
.processCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 20px;
}
.processGrid {
display: grid;
grid-template-columns: auto 1fr auto 1fr;
gap: 6px 16px;
font-size: 12px;
font-family: var(--font-body);
margin-top: 12px;
}
.processLabel {
color: var(--text-muted);
font-weight: 500;
}
.capTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* Route badges */
.routeBadges {
display: flex;
gap: 6px;
@@ -24,9 +82,10 @@
margin-bottom: 20px;
}
/* Charts 3x2 grid */
.chartsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
margin-bottom: 20px;
}
@@ -53,14 +112,46 @@
color: var(--text-primary);
}
.sectionTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
.chartMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.eventCard {
/* Log + Timeline side by side */
.bottomRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
/* Log viewer */
.logCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.logHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
/* Empty state (shared) */
.logEmpty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
/* Timeline card */
.timelineCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
@@ -69,107 +160,12 @@
display: flex;
flex-direction: column;
max-height: 420px;
margin-bottom: 20px;
}
.eventCardHeader {
.timelineHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.infoCard {
margin-bottom: 20px;
}
.infoGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
font-size: 13px;
}
.infoLabel {
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
display: block;
margin-bottom: 2px;
}
.capTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.scopeTrail {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 13px;
flex-wrap: wrap;
}
.scopeLink {
color: var(--text-accent, var(--text-primary));
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;
}
.paneTitle {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
}
.chartMeta {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-mono);
}
.bottomSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 20px;
}
.eventCount {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-mono);
}
.emptyEvents {
padding: 20px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}

View File

@@ -1,18 +1,26 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import { useMemo, useState } from 'react';
import { useParams, Link } from 'react-router';
import {
StatCard, StatusDot, Badge, Card,
LineChart, AreaChart, BarChart, EventFeed, Breadcrumb, Spinner, EmptyState,
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
LogViewer, Tabs, useGlobalFilters,
} from '@cameleer/design-system';
import type { FeedEvent, LogEntry } from '@cameleer/design-system';
import styles from './AgentInstance.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useStatsTimeseries } from '../../api/queries/executions';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import { useGlobalFilters } from '@cameleer/design-system';
const LOG_TABS = [
{ label: 'All', value: 'all' },
{ label: 'Warnings', value: 'warn' },
{ label: 'Errors', value: 'error' },
];
export default function AgentInstance() {
const { appId, instanceId } = useParams();
const { timeRange } = useGlobalFilters();
const [logFilter, setLogFilter] = useState('all');
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
@@ -20,8 +28,8 @@ export default function AgentInstance() {
const { data: events } = useAgentEvents(appId, instanceId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
const agent = useMemo(() =>
(agents || []).find((a: any) => a.id === instanceId) as any,
const agent = useMemo(
() => (agents || []).find((a: any) => a.id === instanceId) as any,
[agents, instanceId],
);
@@ -43,26 +51,34 @@ export default function AgentInstance() {
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,
})),
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(() =>
(events || []).filter((e: any) => !instanceId || e.agentId === 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 ? ' — ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
const feedEvents = useMemo<FeedEvent[]>(
() =>
(events || [])
.filter((e: any) => !instanceId || e.agentId === 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),
})),
[events, instanceId],
);
@@ -88,194 +104,305 @@ export default function AgentInstance() {
const gcSeries = useMemo(() => {
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
if (!pts?.length) return null;
return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: String(i), y: p.value })) }];
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,
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,
const errorSeries = useMemo(
() =>
chartData.length
? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }]
: null,
[chartData],
);
// Placeholder log entries (backend does not stream logs yet)
const logEntries = useMemo<LogEntry[]>(() => [], []);
const filteredLogs =
logFilter === 'all' ? logEntries : logEntries.filter((l) => l.level === logFilter);
if (isLoading) return <Spinner size="lg" />;
return (
<div>
<Breadcrumb items={[
{ label: 'Agents', href: '/agents' },
{ label: appId || '', href: `/agents/${appId}` },
{ label: agent?.name || instanceId || '' },
]} />
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.agentHeader}>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<h2>{agent.name}</h2>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
{agent.version && <Badge label={agent.version} variant="outlined" />}
</div>
<div className={styles.statStrip}>
<StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
<StatCard
label="Memory"
value={memPct != null ? `${memPct.toFixed(0)}%` : '—'}
detail={heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : undefined}
/>
<StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
<StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} />
<StatCard
label="Uptime"
value={formatUptime(agent?.uptimeSeconds)}
detail={agent?.registeredAt ? `since ${new Date(agent.registeredAt).toLocaleDateString()}` : undefined}
/>
</div>
<div className={styles.scopeTrail}>
<a href="/agents" className={styles.scopeLink}>All Agents</a>
<Link to="/agents" className={styles.scopeLink}>
All Agents
</Link>
<span className={styles.scopeSep}>&#9656;</span>
<a href={`/agents/${appId}`} className={styles.scopeLink}>{appId}</a>
<Link to={`/agents/${appId}`} className={styles.scopeLink}>
{appId}
</Link>
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeCurrent}>{agent.name}</span>
<Badge
label={agent.status.toUpperCase()}
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
/>
{agent.version && <Badge label={agent.version} variant="outlined" />}
<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'}
color={
(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'
}
/>
</div>
<Card className={styles.infoCard}>
<div className={styles.paneTitle}>Process Information</div>
<div className={styles.infoGrid}>
{agent?.capabilities?.jvmVersion && (
<div>
<span className={styles.infoLabel}>JVM</span>
<span>{agent.capabilities.jvmVersion}</span>
</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 && (
<div>
<span className={styles.infoLabel}>Camel</span>
<span>{agent.capabilities.camelVersion}</span>
</div>
{agent.capabilities?.camelVersion && (
<>
<span className={styles.processLabel}>Camel</span>
<MonoText size="xs">{agent.capabilities.camelVersion}</MonoText>
</>
)}
{agent?.capabilities?.springBootVersion && (
<div>
<span className={styles.infoLabel}>Spring Boot</span>
<span>{agent.capabilities.springBootVersion}</span>
</div>
{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>
<span className={styles.infoLabel}>Started</span>
<span>{agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'}</span>
</div>
<div>
<span className={styles.infoLabel}>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>
</Card>
<div className={styles.sectionTitle}>Routes</div>
<div className={styles.routeBadges}>
{(agent.routeIds || []).map((r: string) => (
<Badge key={r} label={r} color="auto" />
))}
</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}>
<div className={styles.chartTitle}>CPU Usage</div>
<div className={styles.chartMeta}>{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}</div>
<span className={styles.chartTitle}>CPU Usage</span>
<span className={styles.chartMeta}>
{cpuDisplay != null ? `${cpuDisplay}% current` : ''}
</span>
</div>
{cpuSeries
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
: <EmptyState title="No data" description="No CPU metrics available" />}
{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}>
<div className={styles.chartTitle}>Memory (Heap)</div>
<div className={styles.chartMeta}>{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}</div>
<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} yLabel="MB" height={200} />
: <EmptyState title="No data" description="No heap metrics available" />}
{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}>
<div className={styles.chartTitle}>Throughput</div>
<div className={styles.chartMeta}>{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}</div>
<span className={styles.chartTitle}>Throughput</span>
<span className={styles.chartMeta}>
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
</span>
</div>
{throughputSeries
? <AreaChart series={throughputSeries} yLabel="msg/s" height={200} />
: <EmptyState title="No data" description="No throughput data in range" />}
{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}>
<div className={styles.chartTitle}>Error Rate</div>
<div className={styles.chartMeta}>{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}</div>
<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} yLabel="%" height={200} />
: <EmptyState title="No data" description="No error data in range" />}
{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}>
<div className={styles.chartTitle}>Thread Count</div>
{threadSeries && <div className={styles.chartMeta}>{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active</div>}
<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} yLabel="threads" height={200} />
: <EmptyState title="No data" description="No thread metrics available" />}
{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}>
<div className={styles.chartTitle}>GC Pauses</div>
<span className={styles.chartTitle}>GC Pauses</span>
<span className={styles.chartMeta} />
</div>
{gcSeries
? <BarChart series={gcSeries} yLabel="ms" height={200} />
: <EmptyState title="No data" description="No GC metrics available" />}
{gcSeries ? (
<BarChart series={gcSeries} height={160} yLabel="ms" />
) : (
<EmptyState title="No data" description="No GC metrics available" />
)}
</div>
</div>
<div className={styles.bottomSection}>
<EmptyState title="Application Log" description="Application log streaming is not yet available" />
<div className={styles.eventCard}>
<div className={styles.eventCardHeader}>
<span>Timeline</span>
<span className={styles.eventCount}>{feedEvents.length} events</span>
{/* Log + Timeline side by side */}
<div className={styles.bottomRow}>
<div className={styles.logCard}>
<div className={styles.logHeader}>
<SectionHeader>Application Log</SectionHeader>
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
</div>
{feedEvents.length > 0
? <EventFeed events={feedEvents} maxItems={50} />
: <div className={styles.emptyEvents}>No events in the selected time range.</div>}
{filteredLogs.length > 0 ? (
<LogViewer entries={filteredLogs} maxHeight={360} />
) : (
<div className={styles.logEmpty}>
Application log streaming is not yet available.
</div>
)}
</div>
<div className={styles.timelineCard}>
<div className={styles.timelineHeader}>
<span className={styles.chartTitle}>Timeline</span>
<span className={styles.chartMeta}>{feedEvents.length} events</span>
</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 '';
if (!seconds) return '\u2014';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);