feat: combine process diagram and processor table into toggled card
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m25s
CI / docker (push) Successful in 1m9s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 40s

Dashboard L3 now shows a single Processor Metrics card with
Diagram/Table toggle buttons. The diagram shows native tooltips on
hover with full processor metrics (avg, p99, invocations, error rate,
% time).

Also fixes:
- Chart x-axis uses actual timestamps instead of bucket indices
- formatDurationShort uses locale formatting with max 3 decimals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-12 21:40:43 +02:00
parent 66248f6b1c
commit 98ce7c2204
4 changed files with 60 additions and 28 deletions

View File

@@ -102,6 +102,18 @@ export function DiagramNode({
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
opacity={isSkipped ? 0.35 : undefined} opacity={isSkipped ? 0.35 : undefined}
> >
{/* Processor metrics tooltip */}
{heatmapEntry && (
<title>{[
`${node.id}${heatmapEntry.processorType ? ` (${heatmapEntry.processorType})` : ''}`,
`Avg: ${heatmapEntry.avgDurationMs.toLocaleString(undefined, { maximumFractionDigits: 3 })}ms`,
`P99: ${heatmapEntry.p99DurationMs.toLocaleString(undefined, { maximumFractionDigits: 3 })}ms`,
`Time: ${heatmapEntry.pctOfRoute.toFixed(1)}%`,
heatmapEntry.totalCount != null ? `Invocations: ${heatmapEntry.totalCount.toLocaleString()}` : '',
heatmapEntry.errorRate != null ? `Errors: ${(heatmapEntry.errorRate * 100).toFixed(2)}%` : '',
].filter(Boolean).join('\n')}</title>
)}
{/* Selection ring */} {/* Selection ring */}
{isSelected && ( {isSelected && (
<rect <rect

View File

@@ -21,6 +21,10 @@ export interface LatencyHeatmapEntry {
p99DurationMs: number; p99DurationMs: number;
/** Percentage of total route time this processor consumes (0-100) */ /** Percentage of total route time this processor consumes (0-100) */
pctOfRoute: number; pctOfRoute: number;
/** Additional fields for diagram tooltip (optional — populated by dashboard) */
processorType?: string;
totalCount?: number;
errorRate?: number;
} }
export interface ProcessDiagramProps { export interface ProcessDiagramProps {

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { import {
KpiStrip, KpiStrip,
@@ -247,6 +247,7 @@ function buildKpiItems(
// ── Component ─────────────────────────────────────────────────────────────── // ── Component ───────────────────────────────────────────────────────────────
export default function DashboardL3() { export default function DashboardL3() {
const [processorView, setProcessorView] = useState<'diagram' | 'table'>('diagram');
const { appId, routeId } = useParams<{ appId: string; routeId: string }>(); const { appId, routeId } = useParams<{ appId: string; routeId: string }>();
const selectedEnv = useEnvironmentStore((s) => s.environment); const selectedEnv = useEnvironmentStore((s) => s.environment);
const { timeRange } = useGlobalFilters(); const { timeRange } = useGlobalFilters();
@@ -290,8 +291,8 @@ export default function DashboardL3() {
// ── Chart data ─────────────────────────────────────────────────────────── // ── Chart data ───────────────────────────────────────────────────────────
const chartData = useMemo(() => const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any, i: number) => ({ (timeseries?.buckets || []).map((b: any) => ({
idx: i, time: b.time,
throughput: b.totalCount, throughput: b.totalCount,
p99: b.p99DurationMs, p99: b.p99DurationMs,
errorRate: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0, errorRate: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0,
@@ -299,6 +300,9 @@ export default function DashboardL3() {
[timeseries], [timeseries],
); );
const formatTime = (t: string) =>
new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// ── Processor table rows ──────────────────────────────────────────────── // ── Processor table rows ────────────────────────────────────────────────
const processorRows: ProcessorRow[] = useMemo(() => { const processorRows: ProcessorRow[] = useMemo(() => {
if (!processorMetrics?.length) return []; if (!processorMetrics?.length) return [];
@@ -321,12 +325,15 @@ export default function DashboardL3() {
const totalAvg = processorMetrics.reduce( const totalAvg = processorMetrics.reduce(
(sum: number, m: any) => sum + m.avgDurationMs, 0, (sum: number, m: any) => sum + m.avgDurationMs, 0,
); );
const map = new Map<string, { avgDurationMs: number; p99DurationMs: number; pctOfRoute: number }>(); const map = new Map<string, { avgDurationMs: number; p99DurationMs: number; pctOfRoute: number; processorType?: string; totalCount?: number; errorRate?: number }>();
for (const m of processorMetrics) { for (const m of processorMetrics) {
map.set(m.processorId, { map.set(m.processorId, {
avgDurationMs: m.avgDurationMs, avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs, p99DurationMs: m.p99DurationMs,
pctOfRoute: totalAvg > 0 ? (m.avgDurationMs / totalAvg) * 100 : 0, pctOfRoute: totalAvg > 0 ? (m.avgDurationMs / totalAvg) * 100 : 0,
processorType: m.processorType,
totalCount: m.totalCount,
errorRate: m.errorRate,
}); });
} }
return map; return map;
@@ -352,14 +359,14 @@ export default function DashboardL3() {
{(timeseries?.buckets?.length ?? 0) > 0 && ( {(timeseries?.buckets?.length ?? 0) > 0 && (
<div className={styles.chartRow}> <div className={styles.chartRow}>
<Card title="Throughput"> <Card title="Throughput">
<ThemedChart data={chartData} height={200} xDataKey="idx" yLabel="msg/s"> <ThemedChart data={chartData} height={200} xDataKey="time" xTickFormatter={formatTime} yLabel="msg/s">
<Area dataKey="throughput" name="Throughput" stroke={CHART_COLORS[0]} <Area dataKey="throughput" name="Throughput" stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} /> fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart> </ThemedChart>
</Card> </Card>
<Card title="Latency Percentiles"> <Card title="Latency Percentiles">
<ThemedChart data={chartData} height={200} xDataKey="idx" yLabel="ms"> <ThemedChart data={chartData} height={200} xDataKey="time" xTickFormatter={formatTime} yLabel="ms">
<Line dataKey="p99" name="P99" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} /> <Line dataKey="p99" name="P99" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
<ReferenceLine y={slaThresholdMs} stroke="var(--error)" strokeDasharray="5 3" <ReferenceLine y={slaThresholdMs} stroke="var(--error)" strokeDasharray="5 3"
label={{ value: `SLA ${slaThresholdMs}ms`, position: 'right', fill: 'var(--error)', fontSize: 9 }} /> label={{ value: `SLA ${slaThresholdMs}ms`, position: 'right', fill: 'var(--error)', fontSize: 9 }} />
@@ -367,7 +374,7 @@ export default function DashboardL3() {
</Card> </Card>
<Card title="Error Rate"> <Card title="Error Rate">
<ThemedChart data={chartData} height={200} xDataKey="idx" yLabel="%"> <ThemedChart data={chartData} height={200} xDataKey="time" xTickFormatter={formatTime} yLabel="%">
<Area dataKey="errorRate" name="Error Rate" stroke={CHART_COLORS[1]} <Area dataKey="errorRate" name="Error Rate" stroke={CHART_COLORS[1]}
fill={CHART_COLORS[1]} fillOpacity={0.1} strokeWidth={2} dot={false} /> fill={CHART_COLORS[1]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart> </ThemedChart>
@@ -375,33 +382,42 @@ export default function DashboardL3() {
</div> </div>
)} )}
{/* Process Diagram with Latency Heatmap */} {/* Processor Metrics — toggle between diagram and table */}
{appId && routeId && (
<div className={`${tableStyles.tableSection} ${styles.diagramHeight}`}>
<ProcessDiagram
application={appId}
routeId={routeId}
diagramLayout={diagramLayout}
latencyHeatmap={latencyHeatmap}
/>
</div>
)}
{/* Processor Metrics Table */}
<div className={tableStyles.tableSection}> <div className={tableStyles.tableSection}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Processor Metrics</span> <span className={tableStyles.tableTitle}>Processor Metrics</span>
<div> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className={tableStyles.tableMeta}> <span className={tableStyles.tableMeta}>
{processorRows.length} processor{processorRows.length !== 1 ? 's' : ''} {processorRows.length} processor{processorRows.length !== 1 ? 's' : ''}
</span> </span>
<div className={styles.toggleRow}>
<button
className={`${styles.toggleBtn} ${processorView === 'diagram' ? styles.toggleActive : ''}`}
onClick={() => setProcessorView('diagram')}
>Diagram</button>
<button
className={`${styles.toggleBtn} ${processorView === 'table' ? styles.toggleActive : ''}`}
onClick={() => setProcessorView('table')}
>Table</button>
</div>
</div> </div>
</div> </div>
<DataTable {processorView === 'diagram' && appId && routeId ? (
columns={PROCESSOR_COLUMNS} <div className={styles.diagramHeight}>
data={processorRows} <ProcessDiagram
sortable application={appId}
/> routeId={routeId}
diagramLayout={diagramLayout}
latencyHeatmap={latencyHeatmap}
/>
</div>
) : (
<DataTable
columns={PROCESSOR_COLUMNS}
data={processorRows}
sortable
/>
)}
</div> </div>
{/* Top 5 Errors — hidden if empty */} {/* Top 5 Errors — hidden if empty */}

View File

@@ -15,8 +15,8 @@ export function formatDurationShort(ms: number | undefined): string {
const seconds = Math.round((ms % 60_000) / 1000); const seconds = Math.round((ms % 60_000) / 1000);
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
} }
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; if (ms >= 1000) return `${(ms / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 })}s`;
return `${ms}ms`; return `${ms.toLocaleString(undefined, { maximumFractionDigits: 3 })}ms`;
} }
export function statusLabel(s: string): string { export function statusLabel(s: string): string {