Rewrite the dashboard helpers, metric stores and types #130

Merged
jcoffey-dev merged 1 commits from phase4/dashboard into main 2026-10-19 04:17:40 +00:00
12 changed files with 771 additions and 461 deletions

No files matched your search

@@ -1,10 +1,7 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect, useMemo } from 'react';
@@ -21,33 +18,32 @@ interface DashboardViewProps {
section: string;
}
/** Pages the console draws itself; every other id is one of the schema's trend dashboards. */
const BUILT_IN = new Set(['overview', 'cluster']);
/**
* INBUXA: every dashboard page sits in the same shell, which stays in place
* as you move between them. The landing page is the command center; the
* others are its trend pages, plus Cluster when the server is part of one.
* A dashboard page inside the shell they all share, which stays put while
* moving between them. An id that names no dashboard goes to the overview.
*/
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
const navigate = useNavigate();
const schema = useSchemaStore((s) => s.schema);
const dashboards = useMemo<Dashboard[]>(() => schema?.dashboards ?? [], [schema]);
const dashboard = dashboards.find((d) => d.id === dashboardId);
const own = dashboardId === 'overview' || dashboardId === 'cluster';
const unknown = !BUILT_IN.has(dashboardId) && !dashboard && dashboards.length > 0;
useEffect(() => {
if (!own && !dashboard && dashboards.length > 0) {
navigate(`/${section}/Dashboard/overview`, { replace: true });
}
}, [own, dashboard, dashboards, navigate, section]);
if (unknown) navigate(`/${section}/Dashboard/overview`, { replace: true });
}, [unknown, navigate, section]);
let page = null;
if (dashboardId === 'overview') page = <CommandCenter />;
else if (dashboardId === 'cluster') page = <ClusterView />;
else if (dashboard) page = <TrendsView key={dashboard.id} dashboard={dashboard} />;
return (
<DashboardShell dashboards={dashboards} current={dashboardId} section={section}>
{dashboardId === 'overview' ? (
<CommandCenter />
) : dashboardId === 'cluster' ? (
<ClusterView />
) : dashboard ? (
<TrendsView key={dashboard.id} dashboard={dashboard} />
) : null}
{page}
</DashboardShell>
);
}
+130 -223
View File
@@ -1,237 +1,172 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** Turning metric samples into what the dashboard draws: periods, slices, readings and labels. */
import type { Metric, MetricId, Period } from './types/metrics';
import type { Card, Series, MetricFormat, Aggregate } from './types/schema';
import {
PRESET_MS,
BUCKET_CONFIG,
CUSTOM_BUCKET_COUNT,
HOUR_MS,
MIN_ZOOM_MS,
SPARKLINE_BUCKET_COUNT,
} from './types/metrics';
import { BUCKET_CONFIG, CUSTOM_BUCKET_COUNT, HOUR_MS, MIN_ZOOM_MS, PRESET_MS } from './types/metrics';
import type { Aggregate, Card, Chart, MetricFormat, Series } from './types/schema';
type Histogram = Extract<Metric, { '@type': 'Histogram' }>;
/** One number per sample: a counter's or gauge's count, a histogram's mean. */
export function metricToScalar(m: Metric): number {
if (m['@type'] === 'Histogram') {
return m.count === 0 ? 0 : m.sum / m.count;
}
return m.count;
if (m['@type'] !== 'Histogram') return m.count;
return m.count === 0 ? 0 : m.sum / m.count;
}
// INBUXA: histograms are running totals per node, so they're diffed per
// node; in a cluster, diffing across nodes subtracts one node's total from
// another's.
export function deltaHistograms(samples: Metric[]): Metric[] {
const nonHistograms: Metric[] = [];
const byMetric = new Map<string, Metric[]>();
const timeOf = (m: Metric) => (m.timestamp ? Date.parse(m.timestamp) : 0);
/**
* Histograms arrive as running totals; this turns each into the change
* since the sample before, so a slice shows what happened in it. Totals
* are per node, so each node is diffed on its own: diffing across nodes
* would subtract one node's total from another's. A reset (a total going
* down) yields nothing for that step. Other samples pass through first.
*/
export function deltaHistograms(samples: Metric[]): Metric[] {
const others: Metric[] = [];
const runs = new Map<string, Histogram[]>();
for (const m of samples) {
if (m['@type'] !== 'Histogram') {
nonHistograms.push(m);
others.push(m);
continue;
}
const key = `${m.metric}|${m.nodeId ?? ''}`;
let list = byMetric.get(key);
if (!list) {
list = [];
byMetric.set(key, list);
}
list.push(m);
runs.set(key, [...(runs.get(key) ?? []), m]);
}
const result = [...nonHistograms];
for (const [, records] of byMetric) {
records.sort((a, b) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return ta - tb;
const deltas: Metric[] = [];
for (const run of runs.values()) {
run.sort((a, b) => timeOf(a) - timeOf(b));
run.forEach((curr, i) => {
if (i === 0) return;
const count = curr.count - run[i - 1].count;
const sum = curr.sum - run[i - 1].sum;
if (count > 0 && sum >= 0) {
deltas.push({ '@type': 'Histogram', metric: curr.metric, count, sum, timestamp: curr.timestamp });
}
});
for (let i = 1; i < records.length; i++) {
const prev = records[i - 1] as Metric & { '@type': 'Histogram' };
const curr = records[i] as Metric & { '@type': 'Histogram' };
const deltaCount = curr.count - prev.count;
const deltaSum = curr.sum - prev.sum;
if (deltaCount <= 0 || deltaSum < 0) continue;
result.push({
'@type': 'Histogram',
metric: curr.metric,
count: deltaCount,
sum: deltaSum,
timestamp: curr.timestamp,
});
}
}
return result;
return [...others, ...deltas];
}
/** The period's window; a preset ends now. */
export function periodWindow(p: Period): { from: Date; to: Date } {
if (p.kind === 'custom') return { from: p.from, to: p.to };
const to = new Date();
const ms = PRESET_MS[p.preset];
return { from: new Date(to.getTime() - ms), to };
return { from: new Date(to.getTime() - PRESET_MS[p.preset]), to };
}
/** A key that tells periods apart, for caching. */
export function periodKey(p: Period): string {
if (p.kind === 'custom') return `custom|${p.from.toISOString()}|${p.to.toISOString()}`;
return p.preset;
return p.kind === 'custom' ? `custom|${p.from.toISOString()}|${p.to.toISOString()}` : p.preset;
}
/** How many slices to cut the period into: fixed per preset, a slice per hour for a zoom. */
export function getBucketCount(p: Period): number {
if (p.kind === 'custom') {
// inbuxa: a slice per hour of the window, as history is sampled hourly.
const hours = Math.round((p.to.getTime() - p.from.getTime()) / HOUR_MS);
return Math.min(CUSTOM_BUCKET_COUNT, Math.max(MIN_ZOOM_MS / HOUR_MS, hours));
}
return BUCKET_CONFIG[p.preset];
if (p.kind === 'preset') return BUCKET_CONFIG[p.preset];
const hours = Math.round((p.to.getTime() - p.from.getTime()) / HOUR_MS);
return Math.min(CUSTOM_BUCKET_COUNT, Math.max(MIN_ZOOM_MS / HOUR_MS, hours));
}
function aggregate(values: number[], agg: Aggregate | undefined): number {
function combine(values: number[], how: Aggregate | undefined): number {
if (values.length === 0) return 0;
const sum = values.reduce((a, b) => a + b, 0);
if (agg === 'avg') return sum / values.length;
return sum;
return how === 'avg' ? sum / values.length : sum;
}
export function cardValue(card: Card, samples: Metric[]): number {
const values = samples.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar);
return aggregate(values, card.aggregate);
/**
* A series' value in one slice, or null when it has no samples there. Gauges
* give one reading across the nodes, not every tick's readings combined.
*/
export function seriesBucketValue(series: Series, slice: Metric[]): number | null {
const own = slice.filter((m) => series.metrics.includes(m.metric));
if (own.length === 0) return null;
if (own.every((m) => m['@type'] === 'Gauge')) return gaugeReading(own, series.metrics);
return combine(own.map(metricToScalar), series.aggregate);
}
export function seriesBucketValue(series: Series, bucketSamples: Metric[]): number | null {
const mine = bucketSamples.filter((m) => series.metrics.includes(m.metric));
if (mine.length === 0) return null;
// INBUXA: a gauge's slice is one reading across the nodes, not every tick's readings combined
if (mine.every((m) => m['@type'] === 'Gauge')) return gaugeReading(mine, series.metrics);
return aggregate(mine.map(metricToScalar), series.aggregate);
}
// INBUXA: gauges counting the whole cluster. Only the node that computes
// them has a true reading; history from before inbuxa-server 2026.9.30.2
// also holds the other nodes' copies, with the queue drifted below zero
// (stored unsigned, so a 20-digit number) and the accounts and domains at 0.
/**
* Gauges that count the whole cluster. Only the node that computes them has
* a true reading; history from before inbuxa-server 2026.9.30.2 also holds
* the other nodes' copies, with the queue drifted below zero (stored
* unsigned, so a 20-digit number) and the accounts and domains at 0.
*/
export const CLUSTER_GAUGES = new Set<MetricId>(['queue.count', 'user.count', 'domain.count']);
/**
* The gauges' reading across the cluster: each node's latest, added up for
* per-node gauges (memory, open connections), and for cluster-wide counts the
* largest believable one. Null when there's no reading.
* per-node gauges (memory, open connections), and for cluster-wide counts
* the largest believable one. Null when there's no reading.
*/
export function gaugeReading(samples: Metric[], ids: MetricId[]): number | null {
const last = new Map<string, { metric: MetricId; at: number; value: number }>();
const latest = new Map<string, { metric: MetricId; at: number; value: number }>();
for (const m of samples) {
if (m['@type'] !== 'Gauge' || !ids.includes(m.metric)) continue;
const at = m.timestamp ? Date.parse(m.timestamp) : 0;
const key = `${m.metric}|${m.nodeId ?? ''}`;
const prev = last.get(key);
if (!prev || at >= prev.at) last.set(key, { metric: m.metric, at, value: m.count });
const at = timeOf(m);
if ((latest.get(key)?.at ?? -Infinity) <= at) latest.set(key, { metric: m.metric, at, value: m.count });
}
let sum = 0;
let found = false;
const cluster = new Map<MetricId, number>();
for (const { metric, value } of last.values()) {
if (latest.size === 0) return null;
let total = 0;
let any = false;
const clusterWide = new Map<MetricId, number>();
for (const { metric, value } of latest.values()) {
if (!CLUSTER_GAUGES.has(metric)) {
sum += value;
found = true;
total += value;
any = true;
} else if (value <= Number.MAX_SAFE_INTEGER) {
cluster.set(metric, Math.max(cluster.get(metric) ?? 0, value));
clusterWide.set(metric, Math.max(clusterWide.get(metric) ?? 0, value));
}
}
for (const value of cluster.values()) {
sum += value;
found = true;
for (const value of clusterWide.values()) {
total += value;
any = true;
}
return found ? sum : null;
return any ? total : null;
}
/** Samples by slice of the window; ones outside it, or without a time, are left out. */
export function bucketize(samples: Metric[], from: Date, to: Date, bucketCount: number): Metric[][] {
const buckets: Metric[][] = Array.from({ length: bucketCount }, () => []);
const span = to.getTime() - from.getTime();
if (span <= 0) return buckets;
const slices: Metric[][] = Array.from({ length: bucketCount }, () => []);
const start = from.getTime();
const span = to.getTime() - start;
if (span <= 0) return slices;
for (const m of samples) {
if (!m.timestamp) continue;
const t = new Date(m.timestamp).getTime();
if (t < from.getTime() || t > to.getTime()) continue;
const idx = Math.min(bucketCount - 1, Math.floor(((t - from.getTime()) / span) * bucketCount));
buckets[idx].push(m);
const offset = Date.parse(m.timestamp) - start;
if (offset < 0 || offset > span) continue;
slices[Math.min(bucketCount - 1, Math.floor((offset / span) * bucketCount))].push(m);
}
return buckets;
return slices;
}
/** The middle of each slice, where its point goes on a chart. */
export function bucketTimestamps(from: Date, to: Date, bucketCount: number): Date[] {
const span = to.getTime() - from.getTime();
const width = span / bucketCount;
const width = (to.getTime() - from.getTime()) / bucketCount;
return Array.from({ length: bucketCount }, (_, i) => new Date(from.getTime() + width * (i + 0.5)));
}
export function computeDelta(
card: Card,
samples: Metric[],
from: Date,
to: Date,
): { pct: number; direction: 'up' | 'down' | 'neutral' } | null {
const mid = new Date((from.getTime() + to.getTime()) / 2);
const firstHalf = samples.filter((m) => {
if (!m.timestamp) return false;
return new Date(m.timestamp).getTime() < mid.getTime();
});
const secondHalf = samples.filter((m) => {
if (!m.timestamp) return false;
return new Date(m.timestamp).getTime() >= mid.getTime();
});
const first = aggregate(firstHalf.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar), card.aggregate);
const second = aggregate(
secondHalf.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar),
card.aggregate,
);
if (first === 0) return null;
const pct = ((second - first) / Math.abs(first)) * 100;
if (pct === 0) return { pct: 0, direction: 'neutral' };
return { pct: Math.round(pct * 10) / 10, direction: pct > 0 ? 'up' : 'down' };
}
export function sparklineData(card: Card, samples: Metric[], from: Date, to: Date): number[] {
const buckets = bucketize(samples, from, to, SPARKLINE_BUCKET_COUNT);
return buckets.map((bucket) => {
const values = bucket.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar);
return aggregate(values, card.aggregate);
});
}
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
/** A value as the dashboard shows it, in its metric's format. */
export function formatValue(value: number, format: MetricFormat): string {
switch (format) {
case 'number': {
if (Number.isInteger(value) && Math.abs(value) < 1000) {
return value.toString();
}
case 'number':
if (Number.isInteger(value) && Math.abs(value) < 1000) return String(value);
return new Intl.NumberFormat(undefined, {
notation: Math.abs(value) >= 1000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value);
}
case 'bytes': {
if (value === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
for (let i = units.length - 1; i >= 0; i--) {
const factor = 1024 ** i;
const v = value / factor;
if (v >= 1) {
const decimals = v < 10 ? 1 : 0;
return `${v.toFixed(decimals)} ${units[i]}`;
}
for (let unit = BYTE_UNITS.length - 1; unit >= 0; unit--) {
const scaled = value / 1024 ** unit;
if (scaled >= 1) return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${BYTE_UNITS[unit]}`;
}
return `${value} B`;
}
@@ -239,12 +174,11 @@ export function formatValue(value: number, format: MetricFormat): string {
if (value === 0) return '0 ms';
if (value < 1000) return `${Math.round(value)} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
const m = Math.floor(value / 60_000);
const s = Math.floor((value % 60_000) / 1000);
if (m < 60) return s > 0 ? `${m} m ${s} s` : `${m} m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return rm > 0 ? `${h} h ${rm} m` : `${h} h`;
const minutes = Math.floor(value / 60_000);
const pair = (big: number, bigUnit: string, small: number, smallUnit: string) =>
small > 0 ? `${big} ${bigUnit} ${small} ${smallUnit}` : `${big} ${bigUnit}`;
if (minutes < 60) return pair(minutes, 'm', Math.floor((value % 60_000) / 1000), 's');
return pair(Math.floor(minutes / 60), 'h', minutes % 60, 'm');
}
case 'percent':
return `${value.toFixed(1)}%`;
@@ -253,59 +187,33 @@ export function formatValue(value: number, format: MetricFormat): string {
}
}
/** A chart's time axis label: the time of day over a day or less, the date over longer. */
export function formatTimeTick(date: Date, period: Period): string {
if (period.kind === 'preset') {
switch (period.preset) {
case '24h':
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
default:
return date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
}
}
const span = period.to.getTime() - period.from.getTime();
if (span <= 86_400_000) {
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
const withinDay =
period.kind === 'preset'
? period.preset === '24h'
: period.to.getTime() - period.from.getTime() <= PRESET_MS['24h'];
return withinDay
? date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
}
export function collectHistoryMetricIds(
cards: Card[] | undefined,
charts: import('./types/schema').Chart[] | undefined,
): Set<MetricId> {
const ids = new Set<MetricId>();
if (cards) {
for (const card of cards) {
if (card.source === 'history') {
for (const id of card.metrics) ids.add(id);
}
}
}
if (charts) {
for (const chart of charts) {
for (const series of chart.series) {
for (const id of series.metrics) ids.add(id);
}
}
}
return ids;
/** The metrics to ask history for: the history cards' and every chart's. */
export function collectHistoryMetricIds(cards: Card[] | undefined, charts: Chart[] | undefined): Set<MetricId> {
return new Set([
...(cards ?? []).filter((c) => c.source === 'history').flatMap((c) => c.metrics),
...(charts ?? []).flatMap((chart) => chart.series.flatMap((s) => s.metrics)),
]);
}
/** The metrics to stream live: the live cards'. */
export function collectLiveMetricIds(cards: Card[] | undefined): Set<MetricId> {
const ids = new Set<MetricId>();
if (cards) {
for (const card of cards) {
if (card.source === 'live') {
for (const id of card.metrics) ids.add(id);
}
}
}
return ids;
return new Set((cards ?? []).filter((c) => c.source === 'live').flatMap((c) => c.metrics));
}
/**
* inbuxa: the window a drag across slices `a` to `b` of a chart zooms to: from
* the start of the first slice to the end of the last. A window under three
* The window a drag across slices `a` to `b` of a chart zooms to: from the
* start of the first slice to the end of the last. A window under three
* hours widens around its middle, staying inside the chart's own. Null when
* the chart's window is already that narrow, so there's nothing to zoom into.
*/
@@ -320,26 +228,25 @@ export function zoomWindow(
const end = to.getTime();
if (end - start <= MIN_ZOOM_MS || buckets <= 0) return null;
const width = (end - start) / buckets;
const lo = Math.max(0, Math.min(a, b));
const hi = Math.min(buckets - 1, Math.max(a, b));
let zFrom = start + lo * width;
let zTo = start + (hi + 1) * width;
if (zTo - zFrom < MIN_ZOOM_MS) {
const mid = (zFrom + zTo) / 2;
zFrom = Math.max(start, Math.min(end - MIN_ZOOM_MS, mid - MIN_ZOOM_MS / 2));
zTo = zFrom + MIN_ZOOM_MS;
const first = Math.max(0, Math.min(a, b));
const last = Math.min(buckets - 1, Math.max(a, b));
let zoomFrom = start + first * width;
let zoomTo = start + (last + 1) * width;
if (zoomTo - zoomFrom < MIN_ZOOM_MS) {
const middle = (zoomFrom + zoomTo) / 2;
zoomFrom = Math.max(start, Math.min(end - MIN_ZOOM_MS, middle - MIN_ZOOM_MS / 2));
zoomTo = zoomFrom + MIN_ZOOM_MS;
}
return { from: new Date(Math.round(zFrom)), to: new Date(Math.round(zTo)) };
return { from: new Date(Math.round(zoomFrom)), to: new Date(Math.round(zoomTo)) };
}
/**
* inbuxa: which of `buckets` slices a pointer at `x` is over, on a plot from
* `left` to `right`. Lines and areas put the slices on points, first at the
* left edge and last at the right; bars put each in a band of its own.
* Which of `buckets` slices a pointer at `x` is over, on a plot from `left`
* to `right`. Lines and areas put the slices on points, first at the left
* edge and last at the right; bars put each in a band of its own.
*/
export function sliceAt(x: number, left: number, right: number, buckets: number, band: boolean): number | null {
if (buckets <= 0 || right <= left) return null;
const f = Math.min(1, Math.max(0, (x - left) / (right - left)));
const i = band ? Math.floor(f * buckets) : Math.round(f * (buckets - 1));
return Math.min(buckets - 1, i);
return Math.min(buckets - 1, band ? Math.floor(f * buckets) : Math.round(f * (buckets - 1)));
}
+182
View File
@@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Metric } from './types/metrics';
import { PRESET_KEYS, presetLabel } from './types/metrics';
import type { Card, Chart } from './types/schema';
import {
bucketize,
bucketTimestamps,
collectHistoryMetricIds,
collectLiveMetricIds,
formatTimeTick,
formatValue,
getBucketCount,
metricToScalar,
periodKey,
periodWindow,
} from './helpers';
const at = (iso: string) => new Date(iso);
const counter = (metric: string, count: number, timestamp?: string): Metric => ({
'@type': 'Counter',
metric,
count,
timestamp,
});
afterEach(() => vi.useRealTimers());
describe('metricToScalar', () => {
it('reads counters and gauges as their count, histograms as their mean', () => {
expect(metricToScalar(counter('a', 7))).toBe(7);
expect(metricToScalar({ '@type': 'Gauge', metric: 'g', count: 3 })).toBe(3);
expect(metricToScalar({ '@type': 'Histogram', metric: 'h', count: 4, sum: 10 })).toBe(2.5);
expect(metricToScalar({ '@type': 'Histogram', metric: 'h', count: 0, sum: 10 })).toBe(0);
});
});
describe('periods', () => {
it('ends a preset now and starts it its length before', () => {
vi.useFakeTimers({ now: at('2026-10-18T12:00:00Z') });
expect(periodWindow({ kind: 'preset', preset: '24h' })).toEqual({
from: at('2026-10-17T12:00:00Z'),
to: at('2026-10-18T12:00:00Z'),
});
expect(periodWindow({ kind: 'preset', preset: '90d' }).from).toEqual(at('2026-07-20T12:00:00Z'));
});
it('keeps a custom window as it is', () => {
const p = { kind: 'custom' as const, from: at('2026-10-01T00:00:00Z'), to: at('2026-10-02T00:00:00Z') };
expect(periodWindow(p)).toEqual({ from: p.from, to: p.to });
expect(periodKey(p)).toBe('custom|2026-10-01T00:00:00.000Z|2026-10-02T00:00:00.000Z');
expect(periodKey({ kind: 'preset', preset: '7d' })).toBe('7d');
});
it('slices each preset its own way', () => {
expect(PRESET_KEYS.map((preset) => getBucketCount({ kind: 'preset', preset }))).toEqual([48, 56, 60, 90]);
});
it('names the presets', () => {
const t = (_: string, fallback: string) => fallback;
expect(PRESET_KEYS.map((k) => presetLabel(t, k))).toEqual([
'Last 24 hours',
'Last 7 days',
'Last 30 days',
'Last 90 days',
]);
});
});
describe('bucketize', () => {
const from = at('2026-10-18T00:00:00Z');
const to = at('2026-10-18T04:00:00Z');
it('drops each sample into its slice of the window, the end into the last', () => {
const buckets = bucketize(
[
counter('a', 1, '2026-10-18T00:00:00Z'),
counter('a', 2, '2026-10-18T01:59:59Z'),
counter('a', 3, '2026-10-18T02:00:00Z'),
counter('a', 4, '2026-10-18T04:00:00Z'),
counter('a', 5, '2026-10-18T04:00:01Z'),
counter('a', 6, '2026-10-17T23:59:59Z'),
counter('a', 7),
],
from,
to,
2,
);
expect(buckets.map((b) => b.map((m) => m.count))).toEqual([
[1, 2],
[3, 4],
]);
});
it('gives empty slices for an empty window', () => {
expect(bucketize([counter('a', 1, '2026-10-18T00:00:00Z')], to, to, 3)).toEqual([[], [], []]);
});
it('times each slice by its middle', () => {
expect(bucketTimestamps(from, to, 2)).toEqual([at('2026-10-18T01:00:00Z'), at('2026-10-18T03:00:00Z')]);
});
});
describe('formatValue', () => {
it.each([
[0, 'number', '0'],
[999, 'number', '999'],
[-42, 'number', '-42'],
[1.25, 'number', '1.3'],
[1500, 'number', '1.5K'],
[2_000_000, 'number', '2M'],
[0, 'bytes', '0 B'],
[512, 'bytes', '512 B'],
[1536, 'bytes', '1.5 KB'],
[52_428_800, 'bytes', '50 MB'],
[0.5, 'bytes', '0.5 B'],
[5 * 1024 ** 4, 'bytes', '5.0 TB'],
[0, 'duration', '0 ms'],
[12.4, 'duration', '12 ms'],
[1500, 'duration', '1.5 s'],
[120_000, 'duration', '2 m'],
[125_000, 'duration', '2 m 5 s'],
[3_600_000, 'duration', '1 h'],
[5_520_000, 'duration', '1 h 32 m'],
[12.345, 'percent', '12.3%'],
] as const)('%s as %s is %s', (value, format, text) => {
expect(formatValue(value, format)).toBe(text);
});
it('shows an unknown format as the plain number', () => {
expect(formatValue(3.5, 'other' as never)).toBe('3.5');
});
});
describe('formatTimeTick', () => {
const date = at('2026-10-18T15:30:00Z');
const time = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const day = date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
it('shows the time of day over a day or less, the date over longer', () => {
expect(formatTimeTick(date, { kind: 'preset', preset: '24h' })).toBe(time);
expect(formatTimeTick(date, { kind: 'preset', preset: '7d' })).toBe(day);
const custom = (hours: number) => ({
kind: 'custom' as const,
from: at('2026-10-18T00:00:00Z'),
to: new Date(Date.parse('2026-10-18T00:00:00Z') + hours * 3_600_000),
});
expect(formatTimeTick(date, custom(24))).toBe(time);
expect(formatTimeTick(date, custom(25))).toBe(day);
});
});
describe('metric ids to fetch', () => {
const card = (source: 'live' | 'history', metrics: string[]) =>
({ title: '', icon: '', source, metrics, format: 'number' }) as Card;
const cards = [card('live', ['a', 'b']), card('history', ['c']), card('live', ['b'])];
const charts = [
{
title: '',
kind: 'line',
series: [
{ label: '', metrics: ['d', 'c'] },
{ label: '', metrics: ['e'] },
],
},
] as Chart[];
it('asks history for the history cards and every chart', () => {
expect([...collectHistoryMetricIds(cards, charts)]).toEqual(['c', 'd', 'e']);
expect(collectHistoryMetricIds(undefined, undefined).size).toBe(0);
});
it('asks the live stream for the live cards', () => {
expect([...collectLiveMetricIds(cards)]).toEqual(['a', 'b']);
expect(collectLiveMetricIds(undefined).size).toBe(0);
});
});
+12 -14
View File
@@ -1,16 +1,15 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** The dashboards' shared controls: the period shown, a chart zoom, and refreshing. */
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Period, PresetKey } from '../types/metrics';
/** The automatic refresh choices, in minutes; 0 is off. */
export const AUTO_REFRESH = [0, 1, 5, 10, 15] as const;
export type AutoRefresh = (typeof AUTO_REFRESH)[number];
@@ -19,17 +18,16 @@ interface DashboardState {
setPeriod: (period: Period) => void;
setPreset: (preset: PresetKey) => void;
/**
* inbuxa: the period a chart was zoomed from, while zoomed. Zooming makes the
* period a custom window; only this one is remembered, so a reload is back
* at the preset rather than in an old window.
* While a chart is zoomed, the period it was zoomed from. Zooming makes the
* period a custom window; only this one is saved, so a reload comes back
* at the preset and not in an old window.
*/
zoomedFrom: Period | null;
zoomTo: (from: Date, to: Date) => void;
resetZoom: () => void;
/** INBUXA: minutes between automatic refreshes of the dashboards; 0 is off. */
autoRefresh: AutoRefresh;
setAutoRefresh: (minutes: AutoRefresh) => void;
/** INBUXA: counts refreshes, by hand or on the timer; every panel re-fetches when it moves. */
/** Moves on every refresh, by hand or on the timer; each panel fetches again when it does. */
tick: number;
bump: () => void;
}
@@ -37,22 +35,22 @@ interface DashboardState {
export const useDashboardStore = create<DashboardState>()(
persist(
(set) => ({
period: { kind: 'preset', preset: '24h' } as Period,
period: { kind: 'preset', preset: '24h' },
zoomedFrom: null,
autoRefresh: 5,
tick: 0,
setPeriod: (period) => set({ period, zoomedFrom: null }),
setPreset: (preset) => set({ period: { kind: 'preset', preset }, zoomedFrom: null }),
zoomedFrom: null,
zoomTo: (from, to) =>
set((s) => ({ period: { kind: 'custom', from, to }, zoomedFrom: s.zoomedFrom ?? s.period })),
resetZoom: () => set((s) => (s.zoomedFrom ? { period: s.zoomedFrom, zoomedFrom: null } : {})),
autoRefresh: 5,
setAutoRefresh: (autoRefresh) => set({ autoRefresh }),
tick: 0,
bump: () => set((s) => ({ tick: s.tick + 1 })),
}),
{
name: 'dashboard.period',
partialize: (state) => ({ period: state.zoomedFrom ?? state.period, autoRefresh: state.autoRefresh }),
partialize: (s) => ({ period: s.zoomedFrom ?? s.period, autoRefresh: s.autoRefresh }),
},
),
);
@@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Metric, Period } from '../types/metrics';
const query = vi.fn();
vi.mock('@/services/jmap/client', () => ({
getAccountId: () => 'p',
jmapQueryAllAndGet: (...args: unknown[]) => query(...args),
}));
const { useHistoryMetricsStore } = await import('./historyMetricsStore');
const store = () => useHistoryMetricsStore.getState();
const day: Period = { kind: 'preset', preset: '24h' };
const ids = new Set(['a', 'b']);
const sample = (count: number): Metric => ({ '@type': 'Counter', metric: 'a', count });
beforeEach(() => {
vi.useFakeTimers({ now: new Date('2026-10-18T12:00:00Z') });
query.mockReset();
useHistoryMetricsStore.setState({ cache: new Map(), status: new Map(), error: new Map() });
});
afterEach(() => vi.useRealTimers());
describe('historyMetricsStore', () => {
it('asks for every sample of the metrics in the window', async () => {
query.mockResolvedValue({ ids: ['1'], list: [sample(1)] });
expect(await store().fetch('mail', day, ids)).toEqual([sample(1)]);
expect(query).toHaveBeenCalledWith('x:Metric', 'p', {
filter: {
timestampIsGreaterThanOrEqual: '2026-10-17T12:00:00.000Z',
timestampIsLessThanOrEqual: '2026-10-18T12:00:00.000Z',
metric: ['a', 'b'],
},
});
expect(store().status.get('mail|24h')).toBe('idle');
});
it('asks for nothing when no metrics are wanted', async () => {
expect(await store().fetch('mail', day, new Set())).toEqual([]);
expect(query).not.toHaveBeenCalled();
});
it('reuses an answer for a minute, per dashboard and period', async () => {
query.mockResolvedValue({ ids: [], list: [sample(1)] });
await store().fetch('mail', day, ids);
vi.advanceTimersByTime(59_999);
await store().fetch('mail', day, ids);
expect(query).toHaveBeenCalledTimes(1);
await store().fetch('mail', { kind: 'preset', preset: '7d' }, ids);
await store().fetch('other', day, ids);
expect(query).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(1);
await store().fetch('mail', day, ids);
expect(query).toHaveBeenCalledTimes(4);
});
it('asks once while a fetch is on its way, answering the rest with what it has', async () => {
let answer!: (v: unknown) => void;
query.mockReturnValue(new Promise((r) => (answer = r)));
const first = store().fetch('mail', day, ids);
expect(await store().fetch('mail', day, ids)).toEqual([]);
answer({ ids: [], list: [sample(2)] });
expect(await first).toEqual([sample(2)]);
expect(query).toHaveBeenCalledTimes(1);
});
it('keeps the last numbers when a fetch fails, and says why', async () => {
query.mockResolvedValueOnce({ ids: [], list: [sample(1)] }).mockRejectedValueOnce(new Error('offline'));
await store().fetch('mail', day, ids);
vi.advanceTimersByTime(60_000);
expect(await store().fetch('mail', day, ids)).toEqual([sample(1)]);
expect(store().status.get('mail|24h')).toBe('error');
expect(store().error.get('mail|24h')).toBe('offline');
query.mockResolvedValueOnce({ ids: [], list: [sample(3)] });
vi.advanceTimersByTime(60_000);
await store().fetch('mail', day, ids);
expect(store().error.has('mail|24h')).toBe(false);
});
it('refreshes now, showing the old numbers until the new ones arrive', async () => {
query.mockResolvedValueOnce({ ids: [], list: [sample(1)] });
await store().fetch('mail', day, ids);
let answer!: (v: unknown) => void;
query.mockReturnValueOnce(new Promise((r) => (answer = r)));
const refreshed = store().refresh('mail', day, ids);
expect(store().cache.get('mail|24h')?.metrics).toEqual([sample(1)]);
answer({ ids: [], list: [sample(5)] });
expect(await refreshed).toEqual([sample(5)]);
});
it("forgets a dashboard's answers on invalidate", async () => {
query.mockResolvedValue({ ids: [], list: [] });
await store().fetch('mail', day, ids);
await store().fetch('mailbox', day, ids);
store().invalidate('mail');
expect([...store().cache.keys()]).toEqual(['mailbox|24h']);
});
});
@@ -1,19 +1,22 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Stored metric samples, per dashboard and period. An answer is reused for a
* minute; asking again while a fetch is out waits for nothing and returns
* what's there, and a failed fetch keeps the last numbers.
*/
import { create } from 'zustand';
import type { Metric, MetricId, Period } from '../types/metrics';
import { periodWindow, periodKey } from '../helpers';
import { periodKey, periodWindow } from '../helpers';
import { getAccountId, jmapQueryAllAndGet } from '@/services/jmap/client';
import i18n from '@/i18n';
type FetchStatus = 'idle' | 'loading' | 'error';
const STALE_MS = 60_000;
interface CacheEntry {
@@ -28,87 +31,65 @@ interface HistoryMetricsState {
fetch: (dashboardId: string, period: Period, ids: Set<MetricId>) => Promise<Metric[]>;
invalidate: (dashboardId: string) => void;
/** Fetches again now, still showing the last numbers until the new ones arrive. */
refresh: (dashboardId: string, period: Period, ids: Set<MetricId>) => Promise<Metric[]>;
}
async function fetchMetrics(period: Period, ids: Set<MetricId>): Promise<Metric[]> {
const cacheKey = (dashboardId: string, period: Period) => `${dashboardId}|${periodKey(period)}`;
async function load(period: Period, ids: Set<MetricId>): Promise<Metric[]> {
if (ids.size === 0) return [];
const { from, to } = periodWindow(period);
const accountId = getAccountId('x:Metric');
const metricIds = Array.from(ids);
const result = await jmapQueryAllAndGet('x:Metric', accountId, {
const { list } = await jmapQueryAllAndGet('x:Metric', getAccountId('x:Metric'), {
filter: {
timestampIsGreaterThanOrEqual: from.toISOString(),
timestampIsLessThanOrEqual: to.toISOString(),
metric: metricIds,
metric: [...ids],
},
});
return result.list as Metric[];
return list as Metric[];
}
const withEntry = <V>(map: Map<string, V>, key: string, value: V) => new Map(map).set(key, value);
const without = <V>(map: Map<string, V>, drop: (key: string) => boolean) =>
new Map([...map].filter(([key]) => !drop(key)));
export const useHistoryMetricsStore = create<HistoryMetricsState>()((set, get) => ({
cache: new Map(),
status: new Map(),
error: new Map(),
fetch: async (dashboardId, period, ids) => {
const key = `${dashboardId}|${periodKey(period)}`;
const existing = get().cache.get(key);
if (existing && Date.now() - existing.fetchedAt < STALE_MS) {
return existing.metrics;
}
if (get().status.get(key) === 'loading') {
return existing?.metrics ?? [];
}
set((state) => ({
status: new Map(state.status).set(key, 'loading'),
}));
const key = cacheKey(dashboardId, period);
const cached = get().cache.get(key);
if (cached && Date.now() - cached.fetchedAt < STALE_MS) return cached.metrics;
if (get().status.get(key) === 'loading') return cached?.metrics ?? [];
set((s) => ({ status: withEntry(s.status, key, 'loading') }));
try {
const metrics = await fetchMetrics(period, ids);
set((state) => ({
cache: new Map(state.cache).set(key, { metrics, fetchedAt: Date.now() }),
status: new Map(state.status).set(key, 'idle'),
error: new Map([...state.error].filter(([k]) => k !== key)),
const metrics = await load(period, ids);
set((s) => ({
cache: withEntry(s.cache, key, { metrics, fetchedAt: Date.now() }),
status: withEntry(s.status, key, 'idle'),
error: without(s.error, (k) => k === key),
}));
return metrics;
} catch (e) {
const msg = e instanceof Error ? e.message : i18n.t('dashboard.failedFetchMetrics', 'Failed to fetch metrics');
set((state) => ({
status: new Map(state.status).set(key, 'error'),
error: new Map(state.error).set(key, msg),
}));
return existing?.metrics ?? [];
const message =
e instanceof Error ? e.message : i18n.t('dashboard.failedFetchMetrics', 'Failed to fetch metrics');
set((s) => ({ status: withEntry(s.status, key, 'error'), error: withEntry(s.error, key, message) }));
return cached?.metrics ?? [];
}
},
invalidate: (dashboardId) => {
set((state) => {
const newCache = new Map(state.cache);
for (const key of newCache.keys()) {
if (key.startsWith(`${dashboardId}|`)) {
newCache.delete(key);
}
}
return { cache: newCache };
});
},
invalidate: (dashboardId) => set((s) => ({ cache: without(s.cache, (key) => key.startsWith(`${dashboardId}|`)) })),
refresh: async (dashboardId, period, ids) => {
const key = `${dashboardId}|${periodKey(period)}`;
// INBUXA: mark the entry stale rather than dropping it, so the dashboard
// keeps showing the last numbers until the new ones arrive instead of
// blanking every instrument for the length of the fetch.
set((state) => {
const entry = state.cache.get(key);
if (!entry) return {};
return { cache: new Map(state.cache).set(key, { ...entry, fetchedAt: 0 }) };
refresh: (dashboardId, period, ids) => {
const key = cacheKey(dashboardId, period);
set((s) => {
const cached = s.cache.get(key);
return cached ? { cache: withEntry(s.cache, key, { ...cached, fetchedAt: 0 }) } : {};
});
return get().fetch(dashboardId, period, ids);
},
@@ -52,3 +52,154 @@ describe('live metrics stream URL', () => {
expect(url.startsWith(`${window.location.origin}/api/live/metrics?`)).toBe(true);
});
});
/** Plays the server for the stream's lifecycle. */
class ScriptedSource {
static all: ScriptedSource[] = [];
url: string;
closed = false;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
private listeners: ((e: { data: string }) => void)[] = [];
constructor(url: string) {
this.url = url;
ScriptedSource.all.push(this);
}
addEventListener(name: string, fn: (e: { data: string }) => void) {
if (name === 'metrics') this.listeners.push(fn);
}
close() {
this.closed = true;
}
send(data: unknown) {
for (const fn of this.listeners) fn({ data: JSON.stringify(data) });
}
}
describe('live metrics stream lifecycle', () => {
async function load() {
vi.resetModules();
ScriptedSource.all = [];
vi.stubGlobal('EventSource', ScriptedSource);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('tok 1', { status: 200 })),
);
vi.useFakeTimers();
const { useLiveMetricsStore } = await import('./liveMetricsStore');
current = useLiveMetricsStore;
return useLiveMetricsStore;
}
const latest = () => ScriptedSource.all.at(-1)!;
let current: Awaited<ReturnType<typeof load>> | null = null;
afterEach(() => {
current?.getState().unsubscribe();
current = null;
vi.useRealTimers();
});
it('waits for subscriptions to settle, then streams the metrics every 30 seconds', async () => {
const store = await load();
store.getState().subscribe(new Set(['a']));
store.getState().subscribe(new Set(['a', 'b']));
await vi.advanceTimersByTimeAsync(199);
expect(ScriptedSource.all).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(ScriptedSource.all).toHaveLength(1);
expect(latest().url).toMatch(/\/api\/live\/metrics\?token=tok%201&interval=30&metrics=a%2Cb$/);
expect(store.getState().status).toBe('connecting');
latest().onopen!();
expect(store.getState().status).toBe('open');
});
it('keeps the latest sample of each metric', async () => {
const store = await load();
store.getState().subscribe(new Set(['a', 'b']));
await vi.advanceTimersByTimeAsync(200);
latest().send([
{ '@type': 'Gauge', metric: 'a', count: 1 },
{ '@type': 'Gauge', metric: 'b', count: 2 },
]);
latest().send([{ '@type': 'Gauge', metric: 'a', count: 3 }]);
expect(Object.fromEntries([...store.getState().snapshot].map(([k, m]) => [k, m.count]))).toEqual({ a: 3, b: 2 });
});
it('stays idle for no metrics', async () => {
const store = await load();
store.getState().subscribe(new Set());
await vi.advanceTimersByTimeAsync(200);
expect(ScriptedSource.all).toHaveLength(0);
expect(store.getState().status).toBe('idle');
});
it('reopens a dropped stream two seconds later, with a fresh token, for as long as it takes', async () => {
const store = await load();
store.getState().subscribe(new Set(['a']));
await vi.advanceTimersByTimeAsync(200);
for (let i = 1; i <= 8; i++) {
latest().onerror!();
expect(latest().closed).toBe(true);
expect(store.getState().status).toBe('connecting');
await vi.advanceTimersByTimeAsync(2000);
expect(ScriptedSource.all).toHaveLength(i + 1);
}
expect(fetch).toHaveBeenCalledTimes(9);
store.getState().unsubscribe();
});
it('stops with the reason when it cannot get a token', async () => {
const store = await load();
vi.mocked(fetch).mockRejectedValue(new Error('offline'));
store.getState().subscribe(new Set(['a']));
await vi.advanceTimersByTimeAsync(200);
expect(store.getState()).toMatchObject({ status: 'error', error: 'offline' });
await vi.advanceTimersByTimeAsync(10_000);
expect(ScriptedSource.all).toHaveLength(0);
});
it('closes a minute after the tab is hidden and reopens when it is back', async () => {
const store = await load();
store.getState().subscribe(new Set(['a']));
await vi.advanceTimersByTimeAsync(200);
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(60_000);
expect(latest().closed).toBe(true);
expect(store.getState().status).toBe('closed');
hidden.mockReturnValue(false);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(0);
expect(ScriptedSource.all).toHaveLength(2);
hidden.mockRestore();
store.getState().unsubscribe();
});
it('stays open when the tab comes back within the minute', async () => {
const store = await load();
store.getState().subscribe(new Set(['a']));
await vi.advanceTimersByTimeAsync(200);
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(30_000);
hidden.mockReturnValue(false);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(60_000);
expect(latest().closed).toBe(false);
expect(ScriptedSource.all).toHaveLength(1);
hidden.mockRestore();
store.getState().unsubscribe();
});
it('forgets everything on unsubscribe', async () => {
const store = await load();
store.getState().subscribe(new Set(['a']));
await vi.advanceTimersByTimeAsync(200);
latest().send([{ '@type': 'Gauge', metric: 'a', count: 1 }]);
store.getState().unsubscribe();
expect(latest().closed).toBe(true);
expect(store.getState()).toMatchObject({ status: 'idle', error: null });
expect(store.getState().snapshot.size).toBe(0);
expect(store.getState().subscribedIds.size).toBe(0);
});
});
+79 -122
View File
@@ -1,13 +1,17 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* The live cards' stream: the latest sample of each metric asked for. A
* dropped stream is reopened two seconds later, with a fresh token, for as
* long as it takes. A tab hidden for a minute lets the stream go, and
* opens it again when it's back in view.
*/
import { create } from 'zustand';
import type { MetricId, Metric } from '../types/metrics';
import type { Metric, MetricId } from '../types/metrics';
import { apiFetch, getApiBaseUrl } from '@/services/api';
import i18n from '@/i18n';
@@ -19,130 +23,95 @@ interface LiveMetricsState {
status: LiveStatus;
error: string | null;
/** Streams these metrics from 200 ms on, unless asked for others before then. */
subscribe: (ids: Set<MetricId>) => void;
unsubscribe: () => void;
handleBatch: (batch: Metric[]) => void;
}
let eventSource: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const MAX_RECONNECT = 5;
const RECONNECT_DELAY = 2000;
const SUBSCRIBE_DELAY_MS = 200;
const RECONNECT_DELAY_MS = 2000;
const HIDDEN_CLOSE_MS = 60_000;
const INTERVAL_SECONDS = 30;
function cleanup() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectAttempts = 0;
let stream: EventSource | null = null;
let subscribeTimer: ReturnType<typeof setTimeout> | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let hiddenTimer: ReturnType<typeof setTimeout> | undefined;
function close() {
stream?.close();
stream = null;
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
async function openStream(ids: Set<MetricId>) {
cleanup();
function liveMetricsUrl(token: string, ids: Set<MetricId>): string {
// The API base, not this page's origin: the console may be served apart
// from the server, and its own host would answer with the console itself.
const metrics = encodeURIComponent([...ids].join(','));
return `${getApiBaseUrl()}/api/live/metrics?token=${encodeURIComponent(token)}&interval=${INTERVAL_SECONDS}&metrics=${metrics}`;
}
function keepLatest(batch: Metric[]) {
useLiveMetricsStore.setState((s) => {
const snapshot = new Map(s.snapshot);
for (const m of batch) snapshot.set(m.metric, m);
return { snapshot };
});
}
async function open(ids: Set<MetricId>) {
close();
if (ids.size === 0) {
useLiveMetricsStore.setState({ status: 'idle', subscribedIds: ids });
return;
}
useLiveMetricsStore.setState({ status: 'connecting', subscribedIds: ids, error: null });
let token: string;
try {
const tokenResponse = await apiFetch('/api/token/metrics');
const token = await tokenResponse.text();
const metricsParam = Array.from(ids).join(',');
const url = `${getOrigin()}/api/live/metrics?token=${encodeURIComponent(token)}&interval=30&metrics=${encodeURIComponent(metricsParam)}`;
const es = new EventSource(url);
eventSource = es;
reconnectAttempts = 0;
es.addEventListener('metrics', (event) => {
try {
const batch = JSON.parse(event.data) as Metric[];
useLiveMetricsStore.getState().handleBatch(batch);
} catch (e) {
console.error('Failed to parse live metrics:', e);
}
});
es.onopen = () => {
useLiveMetricsStore.setState({ status: 'open' });
};
es.onerror = () => {
if (es !== eventSource) return;
es.close();
eventSource = null;
if (reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++;
useLiveMetricsStore.setState({ status: 'connecting' });
reconnectTimer = setTimeout(() => {
const currentIds = useLiveMetricsStore.getState().subscribedIds;
openStream(currentIds);
}, RECONNECT_DELAY);
} else {
useLiveMetricsStore.setState({
status: 'error',
error: i18n.t(
'dashboard.liveMetricsDisconnected',
'Live metrics stream disconnected after multiple retries.',
),
});
}
};
token = await (await apiFetch('/api/token/metrics')).text();
} catch (e) {
useLiveMetricsStore.setState({
status: 'error',
error:
e instanceof Error ? e.message : i18n.t('dashboard.liveMetricsFailed', 'Failed to connect to live metrics.'),
});
const message =
e instanceof Error ? e.message : i18n.t('dashboard.liveMetricsFailed', 'Failed to connect to live metrics.');
useLiveMetricsStore.setState({ status: 'error', error: message });
return;
}
const source = new EventSource(liveMetricsUrl(token, ids));
stream = source;
source.addEventListener('metrics', (event) => {
try {
keepLatest(JSON.parse(event.data) as Metric[]);
} catch (e) {
console.error('Failed to parse live metrics:', e);
}
});
source.onopen = () => useLiveMetricsStore.setState({ status: 'open' });
source.onerror = () => {
if (source !== stream) return;
close();
useLiveMetricsStore.setState({ status: 'connecting' });
reconnectTimer = setTimeout(() => void open(useLiveMetricsStore.getState().subscribedIds), RECONNECT_DELAY_MS);
};
}
function getOrigin(): string {
// Honor the deploy-time API base (VITE_API_BASE_URL or the api-base-url
// meta tag) exactly like every other /api/live caller. Using
// window.location.origin here breaks Dashboard live metrics in any split
// deployment where the Admin origin is not the server (e.g. Admin in Docker
// pointed at the server via API_BASE_URL): the EventSource hits the Admin
// nginx instead, which answers with the SPA shell.
return getApiBaseUrl();
}
let visibilityTimer: ReturnType<typeof setTimeout> | null = null;
function handleVisibilityChange() {
function onVisibilityChange() {
if (document.hidden) {
visibilityTimer = setTimeout(() => {
if (eventSource) {
cleanup();
useLiveMetricsStore.setState({ status: 'closed' });
}
}, 60_000);
} else {
if (visibilityTimer) {
clearTimeout(visibilityTimer);
visibilityTimer = null;
}
const state = useLiveMetricsStore.getState();
if (state.status === 'closed' && state.subscribedIds.size > 0) {
openStream(state.subscribedIds);
}
hiddenTimer = setTimeout(() => {
if (!stream) return;
close();
useLiveMetricsStore.setState({ status: 'closed' });
}, HIDDEN_CLOSE_MS);
return;
}
clearTimeout(hiddenTimer);
hiddenTimer = undefined;
const { status, subscribedIds } = useLiveMetricsStore.getState();
if (status === 'closed' && subscribedIds.size > 0) void open(subscribedIds);
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange);
export const useLiveMetricsStore = create<LiveMetricsState>()((set) => ({
snapshot: new Map(),
@@ -151,25 +120,13 @@ export const useLiveMetricsStore = create<LiveMetricsState>()((set) => ({
error: null,
subscribe: (ids) => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
openStream(ids);
}, 200);
clearTimeout(subscribeTimer);
subscribeTimer = setTimeout(() => void open(ids), SUBSCRIBE_DELAY_MS);
},
unsubscribe: () => {
if (debounceTimer) clearTimeout(debounceTimer);
cleanup();
clearTimeout(subscribeTimer);
close();
set({ snapshot: new Map(), subscribedIds: new Set(), status: 'idle', error: null });
},
handleBatch: (batch) => {
set((state) => {
const next = new Map(state.snapshot);
for (const m of batch) {
next.set(m.metric, m);
}
return { snapshot: next };
});
},
}));
+28 -21
View File
@@ -1,29 +1,43 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type MetricId = string;
// INBUXA: nodeId is the node that wrote the sample (servers from 2026.9.30 on)
/**
* A metric sample (`x:Metric`, or the live stream). Counters and histograms
* are running totals; a histogram's mean is `sum / count`. `nodeId` is the
* node that wrote it, on servers from 2026.9.30 on.
*/
export type Metric =
| { '@type': 'Counter'; metric: MetricId; count: number; timestamp?: string; nodeId?: number }
| { '@type': 'Gauge'; metric: MetricId; count: number; timestamp?: string; nodeId?: number }
| { '@type': 'Histogram'; metric: MetricId; count: number; sum: number; timestamp?: string; nodeId?: number };
/** What the dashboard shows: a preset ending now, or a fixed window (a zoom). */
export type Period = { kind: 'preset'; preset: PresetKey } | { kind: 'custom'; from: Date; to: Date };
export type PresetKey = '24h' | '7d' | '30d' | '90d';
export const PRESET_KEYS: PresetKey[] = ['24h', '7d', '30d', '90d'];
const DAY_MS = 86_400_000;
export const PRESET_MS: Record<PresetKey, number> = {
'24h': 86_400_000,
'7d': 7 * 86_400_000,
'30d': 30 * 86_400_000,
'90d': 90 * 86_400_000,
'24h': DAY_MS,
'7d': 7 * DAY_MS,
'30d': 30 * DAY_MS,
'90d': 90 * DAY_MS,
};
/** How many slices a chart cuts each preset into. */
export const BUCKET_CONFIG: Record<PresetKey, number> = {
'24h': 48,
'7d': 56,
'30d': 60,
'90d': 90,
};
export function presetLabel(t: (key: string, fallback: string) => string, key: PresetKey): string {
@@ -39,18 +53,11 @@ export function presetLabel(t: (key: string, fallback: string) => string, key: P
}
}
export const PRESET_KEYS: PresetKey[] = ['24h', '7d', '30d', '90d'];
export const BUCKET_CONFIG: Record<PresetKey, number> = {
'24h': 48,
'7d': 56,
'30d': 60,
'90d': 90,
};
/**
* History is sampled hourly unless set otherwise (`metricsCollectionInterval`),
* so a zoomed window gets a slice per hour, between 3 and this many, and is
* never under 3 hours.
*/
export const CUSTOM_BUCKET_COUNT = 60;
// inbuxa: history is sampled hourly unless set otherwise (`metricsCollectionInterval`),
// so a zoomed window gets a slice per hour, 3 to 60 of them, and is never under 3 hours.
export const HOUR_MS = 3_600_000;
export const MIN_ZOOM_MS = 3 * HOUR_MS;
export const SPARKLINE_BUCKET_COUNT = 20;
+7 -2
View File
@@ -1,9 +1,11 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** The dashboards the server's schema describes (`schema.dashboards`), as it sends them. */
export type Dashboard = {
id: string;
label: string;
@@ -11,10 +13,12 @@ export type Dashboard = {
charts?: Chart[];
};
/** A single figure, from the live stream or from history. */
export type Card = {
title: string;
icon: string;
source: CardSource;
/** The metrics it adds up (or averages, with `aggregate: 'avg'`). */
metrics: string[];
aggregate?: Aggregate;
format: MetricFormat;
@@ -23,6 +27,7 @@ export type Card = {
delta?: boolean;
};
/** A chart over the dashboard's period, one line, area or bar group per series. */
export type Chart = {
title: string;
kind: ChartKind;
+22
View File
@@ -116,4 +116,26 @@ describe('zooming the dashboard period', () => {
const stored = JSON.parse(saved.get('dashboard.period') ?? '{}');
expect(stored.state.period).toEqual({ kind: 'preset', preset: '7d' });
});
it('does nothing on reset when not zoomed', () => {
useDashboardStore.getState().resetZoom();
expect(useDashboardStore.getState().period).toEqual({ kind: 'preset', preset: '7d' });
});
it('ends the zoom when any period is set', () => {
useDashboardStore.getState().zoomTo(at(10), at(16));
useDashboardStore.getState().setPeriod({ kind: 'custom', from: at(0), to: at(48) });
expect(useDashboardStore.getState().zoomedFrom).toBeNull();
expect(useDashboardStore.getState().period).toEqual({ kind: 'custom', from: at(0), to: at(48) });
});
it('saves the auto-refresh choice, and counts refreshes without saving them', () => {
useDashboardStore.getState().setAutoRefresh(15);
const before = useDashboardStore.getState().tick;
useDashboardStore.getState().bump();
expect(useDashboardStore.getState().tick).toBe(before + 1);
const stored = JSON.parse(saved.get('dashboard.period') ?? '{}');
expect(Object.keys(stored.state).sort()).toEqual(['autoRefresh', 'period']);
expect(stored.state.autoRefresh).toBe(15);
});
});
-1
View File
@@ -56,7 +56,6 @@
"customRange": "Custom range",
"failedFetchMetrics": "Failed to fetch metrics",
"from": "From",
"liveMetricsDisconnected": "Live metrics stream disconnected after multiple retries.",
"liveMetricsFailed": "Failed to connect to live metrics.",
"preset24h": "Last 24 hours",
"preset30d": "Last 30 days",