From 3a15a2e1be81192e8b888a81f788c7bbd39127e8 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 15:10:40 -0700 Subject: [PATCH] Cluster roster: each node's mail and connections over the period Admin UX roadmap item 15, "sparklines in tables". Each node's card in the cluster roster gets two strips of the period, with totals: mail (received and sent) and connections opened. They come from the samples that node wrote; servers before 2026.9.30 don't say which node wrote a sample, and then the strips stay hidden. The strip of bars under a readout becomes BarStrip, so the roster draws the same one. 2 new English strings; no other catalogues yet. --- src/features/dashboard/commandCenter.test.ts | 23 ++++++++++++ src/features/dashboard/commandCenter.ts | 33 ++++++++++++++++- .../dashboard/components/ClusterView.tsx | 30 ++++++++++++++- src/features/dashboard/components/Readout.tsx | 37 +++++++++++-------- src/i18n/en.json | 4 +- 5 files changed, 107 insertions(+), 20 deletions(-) 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", -- 2.54.0