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' }}
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 */}
{isSelected && (
<rect

View File

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

View File

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