feat: combine process diagram and processor table into toggled card
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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user