Add route diagram page with execution overlay and group-aware APIs
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:
104
ui/src/components/charts/DurationHistogram.tsx
Normal file
104
ui/src/components/charts/DurationHistogram.tsx
Normal 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} />;
|
||||
}
|
||||
71
ui/src/components/charts/LatencyHeatmap.tsx
Normal file
71
ui/src/components/charts/LatencyHeatmap.tsx
Normal 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} />;
|
||||
}
|
||||
62
ui/src/components/charts/MiniChart.tsx
Normal file
62
ui/src/components/charts/MiniChart.tsx
Normal 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%' }} />;
|
||||
}
|
||||
57
ui/src/components/charts/ThroughputChart.tsx
Normal file
57
ui/src/components/charts/ThroughputChart.tsx
Normal 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} />;
|
||||
}
|
||||
69
ui/src/components/charts/theme.ts
Normal file
69
ui/src/components/charts/theme.ts
Normal 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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user