Rewrite the dashboard helpers, metric stores and types #130
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
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user