Add route diagram page with execution overlay and group-aware APIs
All checks were successful
CI / build (push) Successful in 1m10s
CI / docker (push) Successful in 1m3s
CI / deploy (push) Successful in 31s

Backend: Add group filtering to agent list, search, stats, and timeseries
endpoints. Add diagram lookup by group+routeId. Resolve application group
to agent IDs server-side for ClickHouse IN-clause queries.

Frontend: New route detail page at /apps/{group}/routes/{routeId} with
three tabs (Diagram, Performance, Processor Tree). SVG diagram rendering
with panzoom, execution overlay (glow effects, duration/sequence badges,
flow particles, minimap), and processor detail panel. uPlot charts for
performance tab replacing old SVG sparklines. Ctrl+Click from
ExecutionExplorer navigates to route diagram with overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 21:35:42 +01:00
parent b64edaa16f
commit 7778793e7b
41 changed files with 2770 additions and 26 deletions

View File

@@ -0,0 +1,104 @@
import { useRef, useEffect, useMemo } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface DurationHistogramProps {
buckets: TimeseriesBucket[];
}
export function DurationHistogram({ buckets }: DurationHistogramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
// Build histogram bins from avg durations
const histData = useMemo(() => {
const durations = buckets.map((b) => b.avgDurationMs ?? 0).filter((d) => d > 0);
if (durations.length < 2) return null;
const min = Math.min(...durations);
const max = Math.max(...durations);
const range = max - min || 1;
const binCount = Math.min(20, durations.length);
const binSize = range / binCount;
const bins = new Array(binCount).fill(0);
const labels = new Array(binCount).fill(0);
for (let i = 0; i < binCount; i++) {
labels[i] = Math.round(min + binSize * i + binSize / 2);
}
for (const d of durations) {
const idx = Math.min(Math.floor((d - min) / binSize), binCount - 1);
bins[idx]++;
}
return { xs: labels, counts: bins };
}, [buckets]);
useEffect(() => {
if (!containerRef.current || !histData) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
scales: {
x: { time: false },
},
axes: [
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
size: 40,
},
],
series: [
{ label: 'Duration (ms)' },
{
label: 'Count',
stroke: chartColors.cyan,
fill: `${chartColors.cyan}30`,
width: 2,
paths: (u, seriesIdx, idx0, idx1) => {
const path = new Path2D();
const fillPath = new Path2D();
const barWidth = Math.max(2, (u.bbox.width / (idx1 - idx0 + 1)) * 0.7);
const yBase = u.valToPos(0, 'y', true);
fillPath.moveTo(u.valToPos(0, 'x', true), yBase);
for (let i = idx0; i <= idx1; i++) {
const x = u.valToPos(u.data[0][i], 'x', true) - barWidth / 2;
const y = u.valToPos(u.data[seriesIdx][i] ?? 0, 'y', true);
path.rect(x, y, barWidth, yBase - y);
fillPath.rect(x, y, barWidth, yBase - y);
}
return { stroke: path, fill: fillPath };
},
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [histData.xs, histData.counts], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [histData]);
if (!histData) return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Not enough data for histogram</div>;
return <div ref={containerRef} />;
}

View File

@@ -0,0 +1,71 @@
import { useRef, useEffect } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface LatencyHeatmapProps {
buckets: TimeseriesBucket[];
}
export function LatencyHeatmap({ buckets }: LatencyHeatmapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!containerRef.current || buckets.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
const avgDurations = buckets.map((b) => b.avgDurationMs ?? 0);
const p99Durations = buckets.map((b) => b.p99DurationMs ?? 0);
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
series: [
{ label: 'Time' },
{
label: 'Avg Duration',
stroke: chartColors.cyan,
width: 2,
dash: [4, 2],
},
{
label: 'P99 Duration',
stroke: chartColors.amber,
fill: `${chartColors.amber}15`,
width: 2,
},
],
axes: [
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
size: 50,
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [xs, avgDurations, p99Durations], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [buckets]);
if (buckets.length < 2) return null;
return <div ref={containerRef} />;
}

View File

@@ -0,0 +1,62 @@
import { useRef, useEffect, useMemo } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { sparkOpts, accentHex } from './theme';
interface MiniChartProps {
data: number[];
color: string;
}
export function MiniChart({ data, color }: MiniChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
// Trim first/last buckets (partial time windows) like the old Sparkline
const trimmed = useMemo(() => (data.length > 4 ? data.slice(1, -1) : data), [data]);
const resolvedColor = color.startsWith('#') || color.startsWith('rgb')
? color
: accentHex(color);
useEffect(() => {
if (!containerRef.current || trimmed.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 200;
const h = 24;
// x-axis: simple index values
const xs = Float64Array.from(trimmed, (_, i) => i);
const ys = Float64Array.from(trimmed);
const opts: uPlot.Options = {
...sparkOpts(w, h),
width: w,
height: h,
series: [
{},
{
stroke: resolvedColor,
width: 1.5,
fill: `${resolvedColor}30`,
},
],
};
if (chartRef.current) {
chartRef.current.destroy();
}
chartRef.current = new uPlot(opts, [xs as unknown as number[], ys as unknown as number[]], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [trimmed, resolvedColor]);
if (trimmed.length < 2) return null;
return <div ref={containerRef} style={{ marginTop: 10, height: 24, width: '100%' }} />;
}

View File

@@ -0,0 +1,57 @@
import { useRef, useEffect } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface ThroughputChartProps {
buckets: TimeseriesBucket[];
}
export function ThroughputChart({ buckets }: ThroughputChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!containerRef.current || buckets.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
const totals = buckets.map((b) => b.totalCount ?? 0);
const failed = buckets.map((b) => b.failedCount ?? 0);
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
series: [
{ label: 'Time' },
{
label: 'Total',
stroke: chartColors.amber,
fill: `${chartColors.amber}20`,
width: 2,
},
{
label: 'Failed',
stroke: chartColors.rose,
fill: `${chartColors.rose}20`,
width: 2,
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [xs, totals, failed], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [buckets]);
if (buckets.length < 2) return null;
return <div ref={containerRef} />;
}

View File

@@ -0,0 +1,69 @@
import type uPlot from 'uplot';
/** Shared uPlot color tokens matching Cameleer3 design system */
export const chartColors = {
amber: '#f0b429',
cyan: '#22d3ee',
rose: '#f43f5e',
green: '#10b981',
blue: '#3b82f6',
purple: '#a855f7',
grid: 'rgba(30, 45, 61, 0.5)',
axis: '#4a5e7a',
text: '#8b9cb6',
bg: '#111827',
cursor: 'rgba(240, 180, 41, 0.15)',
} as const;
export type AccentColor = keyof typeof chartColors;
/** Resolve an accent name to a CSS hex color */
export function accentHex(accent: string): string {
return (chartColors as Record<string, string>)[accent] ?? chartColors.amber;
}
/** Base uPlot options shared across all Cameleer3 charts */
export function baseOpts(width: number, height: number): Partial<uPlot.Options> {
return {
width,
height,
cursor: {
show: true,
x: true,
y: false,
},
legend: { show: false },
axes: [
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
ticks: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
ticks: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
size: 50,
},
],
};
}
/** Mini sparkline chart options (no axes, no cursor) */
export function sparkOpts(width: number, height: number): Partial<uPlot.Options> {
return {
width,
height,
cursor: { show: false },
legend: { show: false },
axes: [
{ show: false },
{ show: false },
],
scales: {
x: { time: false },
},
};
}

View File

@@ -1,5 +1,5 @@
import styles from './shared.module.css';
import { Sparkline } from './Sparkline';
import { MiniChart } from '../charts/MiniChart';
const ACCENT_COLORS: Record<string, string> = {
amber: 'var(--amber)',
@@ -27,7 +27,7 @@ export function StatCard({ label, value, accent, change, changeDirection = 'neut
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
)}
{sparkData && sparkData.length >= 2 && (
<Sparkline data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
<MiniChart data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
)}
</div>
);