Cluster roster: each node's mail and connections over the period #80
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
@@ -278,6 +291,21 @@ export function ClusterView() {
|
||||
style={{ width: `${Math.max(2, share * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{perNode && node.nodeId !== undefined && (
|
||||
<div className="mt-3 space-y-1.5 border-t pt-2.5">
|
||||
{nodeStrips(Number(node.nodeId)).map((row) => (
|
||||
<div key={row.label} className="grid grid-cols-[6.5rem_1fr] items-end gap-2">
|
||||
<div className="flex items-baseline justify-between gap-1 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span className="truncate">{row.label}</span>
|
||||
<span className="font-mono text-xs font-semibold tabular-nums text-foreground">
|
||||
{n(row.total)}
|
||||
</span>
|
||||
</div>
|
||||
<BarStrip bars={row.bars} tone={up ? 'primary' : 'crit'} className="h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 = (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -38,21 +37,7 @@ export function Readout({ label, value, detail, bars, tone = 'primary', href }:
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-2xl font-semibold leading-none tabular-nums tracking-tight">{value}</div>
|
||||
{detail && <div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>}
|
||||
{bars && bars.length > 0 && (
|
||||
<div className="mt-auto flex h-5 items-end gap-px pt-2" aria-hidden>
|
||||
{bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 rounded-[1px]"
|
||||
style={{
|
||||
height: `${Math.max(8, (b / max) * 100)}%`,
|
||||
background: TONE_VAR[tone],
|
||||
opacity: b === 0 ? 0.15 : 0.35 + 0.65 * (b / max),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bars && bars.length > 0 && <BarStrip bars={bars} tone={tone} className="mt-auto pt-2" />}
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className={cn('flex h-5 items-end gap-px', className)} aria-hidden>
|
||||
{bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 rounded-[1px]"
|
||||
style={{
|
||||
height: `${Math.max(8, (b / max) * 100)}%`,
|
||||
background: TONE_VAR[tone],
|
||||
opacity: b === 0 ? 0.15 : 0.35 + 0.65 * (b / max),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user