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 { 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);
return useQuery({
queryKey: ['agent-metrics', agentId, names.join(','), buckets],
queryKey: ['agent-metrics', agentId, names.join(','), buckets, from, to],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams({
names: names.join(','),
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}`, {
headers: {
Authorization: `Bearer ${token}`,

View File

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

View File

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

View File

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

View File

@@ -42,9 +42,18 @@
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 {
height: 280px;
flex: 1;
min-height: 280px;
}
/* Chart fill */