diff --git a/src/features/dashboard/commandCenter.test.ts b/src/features/dashboard/commandCenter.test.ts index 8ab2ebd..fa0b7e6 100644 --- a/src/features/dashboard/commandCenter.test.ts +++ b/src/features/dashboard/commandCenter.test.ts @@ -10,6 +10,7 @@ import { cleanShare, latest, load, + nodeSeries, mean, nearQuota, queueHealth, @@ -138,3 +139,25 @@ describe('cleanShare', () => { expect(cleanShare([])).toBe(1); }); }); + +describe('nodeSeries', () => { + const from = new Date('2026-10-05T00:00:00Z'); + const to = new Date('2026-10-05T04:00:00Z'); + const count = (hour: number, n: number, nodeId?: number): Metric => ({ + '@type': 'Counter', + metric: 'queue.message-queued', + count: n, + timestamp: `2026-10-05T0${hour}:30:00Z`, + nodeId, + }); + const samples = [count(0, 3, 1), count(0, 5, 2), count(2, 4, 1), count(3, 7, 2)]; + + it('counts only what the node wrote, slice by slice', () => { + expect(nodeSeries(samples, 1, ['queue.message-queued'], from, to, 4)).toEqual([3, 0, 4, 0]); + expect(nodeSeries(samples, 2, ['queue.message-queued'], from, to, 4)).toEqual([5, 0, 0, 7]); + }); + + it('finds nothing in samples that don’t say which node wrote them', () => { + expect(nodeSeries([count(0, 3), count(1, 2)], 1, ['queue.message-queued'], from, to, 4)).toEqual([0, 0, 0, 0]); + }); +}); diff --git a/src/features/dashboard/commandCenter.ts b/src/features/dashboard/commandCenter.ts index 52e7723..975171a 100644 --- a/src/features/dashboard/commandCenter.ts +++ b/src/features/dashboard/commandCenter.ts @@ -187,7 +187,7 @@ export function summarize(samples: Metric[], ids: string[], timing: boolean): nu return total(samples, ids); } -const CONNECTION_STARTS = [ +export const CONNECTION_STARTS = [ 'smtp.connection-start', 'imap.connection-start', 'pop3.connection-start', @@ -224,7 +224,36 @@ export const STORE_ERRORS = [ 'store.http-store-error', ]; export const THREAD_ERRORS = ['server.thread-error']; -export const CLUSTER_IDS = new Set([...COORDINATION, ...STORE_ERRORS, ...THREAD_ERRORS]); +/** inbuxa: what each node handled, for the strips on its card in the cluster roster. */ +export const NODE_MAIL = [...RECEIVED, ...SENT]; +export const CLUSTER_IDS = new Set([ + ...COORDINATION, + ...STORE_ERRORS, + ...THREAD_ERRORS, + ...NODE_MAIL, + ...CONNECTION_STARTS, +]); + +/** + * inbuxa: one node's share of `series`: only the samples it wrote. Servers + * before 2026.9.30 don't say which node wrote a sample, so nothing matches. + */ +export function nodeSeries( + samples: Metric[], + nodeId: number, + ids: string[], + from: Date, + to: Date, + buckets: number, +): number[] { + return series( + samples.filter((m) => m.nodeId === nodeId), + ids, + from, + to, + buckets, + ); +} /** A node is reported stale when it hasn't renewed its lease in this long. */ export const STALE_AFTER_MS = 3 * 60_000; diff --git a/src/features/dashboard/components/ClusterView.tsx b/src/features/dashboard/components/ClusterView.tsx index 83cecd7..de7aeea 100644 --- a/src/features/dashboard/components/ClusterView.tsx +++ b/src/features/dashboard/components/ClusterView.tsx @@ -15,12 +15,15 @@ import { useLiveMetricsStore } from '../stores/liveMetricsStore'; import { formatValue, getBucketCount } from '../helpers'; import { CLUSTER_IDS, + CONNECTION_STARTS, COORDINATION, LIVE_IDS, STALE_AFTER_MS, STORE_ERRORS, THREAD_ERRORS, + NODE_MAIL, cleanShare, + nodeSeries, series, total, } from '../commandCenter'; @@ -30,7 +33,7 @@ import { usePageHistory } from '../usePageHistory'; import type { Tone } from '../tones'; import { Gauge } from './Gauge'; import { Panel } from './Panel'; -import { Readout } from './Readout'; +import { BarStrip, Readout } from './Readout'; import { TrendChart } from './TrendChart'; const NODES_VIEW = 'x:ClusterNode'; @@ -145,6 +148,16 @@ export function ClusterView() { ]; const nodesHref = nodesSection && canViewObject(NODES_VIEW) ? `/${nodesSection}/${NODES_VIEW}` : null; const n = (v: number) => formatValue(v, 'number'); + // inbuxa: each node's own traffic, from servers that say which node wrote a sample. + const perNode = samples.some((m) => m.nodeId !== undefined); + const nodeStrips = (nodeId: number) => + [ + { label: t('cc.nodeMail', 'Mail'), ids: NODE_MAIL }, + { label: t('cc.nodeConnections', 'Connections'), ids: CONNECTION_STARTS }, + ].map((row) => { + const bars = nodeSeries(samples, nodeId, row.ids, window.from, window.to, STRIP); + return { ...row, bars, total: bars.reduce((a, b) => a + b, 0) }; + }); return (
@@ -278,6 +291,21 @@ export function ClusterView() { style={{ width: `${Math.max(2, share * 100)}%` }} />
+ {perNode && node.nodeId !== undefined && ( +
+ {nodeStrips(Number(node.nodeId)).map((row) => ( +
+
+ {row.label} + + {n(row.total)} + +
+ +
+ ))} +
+ )} ); })} diff --git a/src/features/dashboard/components/Readout.tsx b/src/features/dashboard/components/Readout.tsx index 0f51f2a..d1e7355 100644 --- a/src/features/dashboard/components/Readout.tsx +++ b/src/features/dashboard/components/Readout.tsx @@ -25,7 +25,6 @@ interface ReadoutProps { * strip of bars showing how it moved over the period. */ export function Readout({ label, value, detail, bars, tone = 'primary', href }: ReadoutProps) { - const max = bars ? Math.max(1, ...bars) : 1; const body = (
{value}
{detail &&
{detail}
} - {bars && bars.length > 0 && ( -
- {bars.map((b, i) => ( - - ))} -
- )} + {bars && bars.length > 0 && }
); return href ? ( @@ -66,3 +51,23 @@ export function Readout({ label, value, detail, bars, tone = 'primary', href }: body ); } + +/** The period in slices, as a strip of bars: taller and brighter as the slice is busier. */ +export function BarStrip({ bars, tone = 'primary', className }: { bars: number[]; tone?: Tone; className?: string }) { + const max = Math.max(1, ...bars); + return ( +
+ {bars.map((b, i) => ( + + ))} +
+ ); +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 8ac449f..e840d08 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1954,7 +1954,9 @@ "average": "average", "zoomReset": "Back to {{period}}", "zoomed": "Zoom", - "dragToZoom": "Drag to zoom" + "dragToZoom": "Drag to zoom", + "nodeMail": "Mail", + "nodeConnections": "Connections" }, "commands": { "actionHint": "Server action",