feat: rework runtime charts and fix time range propagation
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m28s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s

Runtime page (AgentInstance):
- Rearrange charts: CPU, Memory, GC (top); Threads, Chunks Exported,
  Chunks Dropped (bottom). Removes throughput/error charts (belong on
  Dashboard, not Runtime).
- Pass global time range (from/to) to useAgentMetrics — charts now
  respect the time filter instead of always showing last 60 minutes.
- Bottom row (logs + timeline) fills remaining vertical space.

Dashboard L3:
- Processor metrics section fills remaining vertical space.
- Chart x-axis uses timestamps instead of bucket indices.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-12 21:59:38 +02:00
parent 98ce7c2204
commit 00c9a0006e
5 changed files with 75 additions and 68 deletions

View File

@@ -3,16 +3,24 @@ import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
import { useRefreshInterval } from './use-refresh-interval'; import { useRefreshInterval } from './use-refresh-interval';
export function useAgentMetrics(agentId: string | null, names: string[], buckets = 60) { export function useAgentMetrics(
agentId: string | null,
names: string[],
buckets = 60,
from?: string,
to?: string,
) {
const refetchInterval = useRefreshInterval(30_000); const refetchInterval = useRefreshInterval(30_000);
return useQuery({ return useQuery({
queryKey: ['agent-metrics', agentId, names.join(','), buckets], queryKey: ['agent-metrics', agentId, names.join(','), buckets, from, to],
queryFn: async () => { queryFn: async () => {
const token = useAuthStore.getState().accessToken; const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams({ const params = new URLSearchParams({
names: names.join(','), names: names.join(','),
buckets: String(buckets), buckets: String(buckets),
}); });
if (from) params.set('from', from);
if (to) params.set('to', to);
const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, { const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,

View File

@@ -105,11 +105,13 @@
font-family: var(--font-mono); font-family: var(--font-mono);
} }
/* Log + Timeline side by side */ /* Log + Timeline side by side — fill remaining vertical space */
.bottomRow { .bottomRow {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 14px; gap: 14px;
flex: 1;
min-height: 300px;
} }
/* Timeline card — card styling via sectionStyles.section */ /* Timeline card — card styling via sectionStyles.section */
@@ -117,7 +119,6 @@
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 420px;
} }
.timelineHeader { .timelineHeader {

View File

@@ -13,7 +13,6 @@ import logStyles from '../../styles/log-panel.module.css';
import chartCardStyles from '../../styles/chart-card.module.css'; import chartCardStyles from '../../styles/chart-card.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs'; import { useApplicationLogs } from '../../api/queries/logs';
import { useStatsTimeseries } from '../../api/queries/executions';
import { useAgentMetrics } from '../../api/queries/agent-metrics'; import { useAgentMetrics } from '../../api/queries/agent-metrics';
import { formatUptime, mapLogLevel, eventSeverity, eventIcon } from '../../utils/agent-utils'; import { formatUptime, mapLogLevel, eventSeverity, eventIcon } from '../../utils/agent-utils';
import { useEnvironmentStore } from '../../api/environment-store'; import { useEnvironmentStore } from '../../api/environment-store';
@@ -47,7 +46,6 @@ export default function AgentInstance() {
const selectedEnv = useEnvironmentStore((s) => s.environment); const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: agents, isLoading } = useAgents(undefined, appId, selectedEnv); const { data: agents, isLoading } = useAgents(undefined, appId, selectedEnv);
const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo, selectedEnv); const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo, selectedEnv);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId, selectedEnv);
const agent = useMemo( const agent = useMemo(
() => (agents || []).find((a: any) => a.instanceId === instanceId) as any, () => (agents || []).find((a: any) => a.instanceId === instanceId) as any,
@@ -59,33 +57,26 @@ export default function AgentInstance() {
agent?.instanceId || null, agent?.instanceId || null,
['process.cpu.usage.value', 'jvm.memory.used.value', 'jvm.memory.max.value'], ['process.cpu.usage.value', 'jvm.memory.used.value', 'jvm.memory.max.value'],
1, 1,
timeFrom, timeTo,
); );
const cpuPct = latestMetrics?.metrics?.['process.cpu.usage.value']?.[0]?.value; const cpuPct = latestMetrics?.metrics?.['process.cpu.usage.value']?.[0]?.value;
const heapUsed = latestMetrics?.metrics?.['jvm.memory.used.value']?.[0]?.value; const heapUsed = latestMetrics?.metrics?.['jvm.memory.used.value']?.[0]?.value;
const heapMax = latestMetrics?.metrics?.['jvm.memory.max.value']?.[0]?.value; const heapMax = latestMetrics?.metrics?.['jvm.memory.max.value']?.[0]?.value;
const memPct = heapMax ? (heapUsed! / heapMax) * 100 : undefined; const memPct = heapMax ? (heapUsed! / heapMax) * 100 : undefined;
// Chart metrics (60 buckets) // Chart metrics (60 buckets, time-range-aware)
const { data: jvmMetrics } = useAgentMetrics( const { data: jvmMetrics } = useAgentMetrics(
agent?.instanceId || null, agent?.instanceId || null,
['process.cpu.usage.value', 'jvm.memory.used.value', 'jvm.memory.max.value', 'jvm.threads.live.value', 'jvm.gc.pause.total_time'], ['process.cpu.usage.value', 'jvm.memory.used.value', 'jvm.memory.max.value', 'jvm.threads.live.value', 'jvm.gc.pause.total_time'],
60, 60, timeFrom, timeTo,
); );
const chartData = useMemo(() => { // Agent pipeline metrics (60 buckets, time-range-aware)
const buckets: any[] = timeseries?.buckets || []; const { data: agentMetrics } = useAgentMetrics(
// Compute bucket duration in seconds from consecutive timestamps (for msg/s conversion) agent?.instanceId || null,
const bucketSecs = ['cameleer.chunks.exported.count', 'cameleer.chunks.dropped.count'],
buckets.length >= 2 60, timeFrom, timeTo,
? (new Date(buckets[1].timestamp).getTime() - new Date(buckets[0].timestamp).getTime()) / 1000 );
: 60;
return buckets.map((b: any) => ({
time: b.timestamp,
throughput: bucketSecs > 0 ? b.totalCount / bucketSecs : b.totalCount,
latency: b.avgDurationMs,
errorPct: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0,
}));
}, [timeseries]);
const feedEvents = useMemo<FeedEvent[]>(() => { const feedEvents = useMemo<FeedEvent[]>(() => {
const mapped = (events || []) const mapped = (events || [])
@@ -127,15 +118,17 @@ export default function AgentInstance() {
return pts.map((p: any) => ({ time: p.time, gc: p.value })); return pts.map((p: any) => ({ time: p.time, gc: p.value }));
}, [jvmMetrics]); }, [jvmMetrics]);
const throughputData = useMemo(() => { const chunksExportedData = useMemo(() => {
if (!chartData.length) return []; const pts = agentMetrics?.metrics?.['cameleer.chunks.exported.count'];
return chartData.map((d: any) => ({ time: d.time, throughput: d.throughput })); if (!pts?.length) return [];
}, [chartData]); return pts.map((p: any) => ({ time: p.time, exported: p.value }));
}, [agentMetrics]);
const errorData = useMemo(() => { const chunksDroppedData = useMemo(() => {
if (!chartData.length) return []; const pts = agentMetrics?.metrics?.['cameleer.chunks.dropped.count'];
return chartData.map((d: any) => ({ time: d.time, errorPct: d.errorPct })); if (!pts?.length) return [];
}, [chartData]); return pts.map((p: any) => ({ time: p.time, dropped: p.value }));
}, [agentMetrics]);
// Application logs // Application logs
const { data: rawLogs } = useApplicationLogs(appId, instanceId, { toOverride: logRefreshTo, source: logSource || undefined }); const { data: rawLogs } = useApplicationLogs(appId, instanceId, { toOverride: logRefreshTo, source: logSource || undefined });
@@ -350,37 +343,17 @@ export default function AgentInstance() {
<div className={chartCardStyles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}> <div className={styles.chartHeader}>
<span className={styles.chartTitle}>Throughput</span> <span className={styles.chartTitle}>GC Pauses</span>
<span className={styles.chartMeta}> <span className={styles.chartMeta} />
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
</span>
</div> </div>
{throughputData.length ? ( {gcData.length ? (
<ThemedChart data={throughputData} height={160} xDataKey="time" <ThemedChart data={gcData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="msg/s"> xTickFormatter={formatTime} yLabel="ms">
<Line dataKey="throughput" name="msg/s" stroke={CHART_COLORS[0]} <Area dataKey="gc" name="GC ms" stroke={CHART_COLORS[1]}
strokeWidth={2} dot={false} /> fill={CHART_COLORS[1]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart> </ThemedChart>
) : ( ) : (
<EmptyState title="No data" description="No throughput data in range" /> <EmptyState title="No data" description="No GC metrics available" />
)}
</div>
<div className={chartCardStyles.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>
{errorData.length ? (
<ThemedChart data={errorData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="%">
<Line dataKey="errorPct" name="Error %" stroke={CHART_COLORS[0]}
strokeWidth={2} dot={false} />
</ThemedChart>
) : (
<EmptyState title="No data" description="No error data in range" />
)} )}
</div> </div>
@@ -406,17 +379,33 @@ export default function AgentInstance() {
<div className={chartCardStyles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}> <div className={styles.chartHeader}>
<span className={styles.chartTitle}>GC Pauses</span> <span className={styles.chartTitle}>Chunks Exported</span>
<span className={styles.chartMeta} /> <span className={styles.chartMeta} />
</div> </div>
{gcData.length ? ( {chunksExportedData.length ? (
<ThemedChart data={gcData} height={160} xDataKey="time" <ThemedChart data={chunksExportedData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="ms"> xTickFormatter={formatTime} yLabel="chunks">
<Area dataKey="gc" name="GC ms" stroke={CHART_COLORS[1]} <Area dataKey="exported" name="Exported" stroke={CHART_COLORS[0]}
fill={CHART_COLORS[1]} fillOpacity={0.1} strokeWidth={2} dot={false} /> fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart> </ThemedChart>
) : ( ) : (
<EmptyState title="No data" description="No GC metrics available" /> <EmptyState title="No data" description="No chunk export metrics available" />
)}
</div>
<div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}>
<span className={styles.chartTitle}>Chunks Dropped</span>
<span className={styles.chartMeta} />
</div>
{chunksDroppedData.length ? (
<ThemedChart data={chunksDroppedData} height={160} xDataKey="time"
xTickFormatter={formatTime} yLabel="chunks">
<Area dataKey="dropped" name="Dropped" stroke="var(--error)"
fill="var(--error)" fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart>
) : (
<EmptyState title="No data" description="No chunk drop metrics available" />
)} )}
</div> </div>
</div> </div>

View File

@@ -383,7 +383,7 @@ export default function DashboardL3() {
)} )}
{/* Processor Metrics — toggle between diagram and table */} {/* Processor Metrics — toggle between diagram and table */}
<div className={tableStyles.tableSection}> <div className={`${tableStyles.tableSection} ${styles.processorSection}`}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Processor Metrics</span> <span className={tableStyles.tableTitle}>Processor Metrics</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>

View File

@@ -42,9 +42,18 @@
text-decoration: underline; text-decoration: underline;
} }
/* Diagram container — height override; card styling via tableStyles.tableSection */ /* Processor section — fills remaining vertical space */
.processorSection {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Diagram container — fills remaining space inside processor section */
.diagramHeight { .diagramHeight {
height: 280px; flex: 1;
min-height: 280px;
} }
/* Chart fill */ /* Chart fill */