diff --git a/src/features/dashboard/components/DashboardView.tsx b/src/features/dashboard/components/DashboardView.tsx index e3dfbac..7041978 100644 --- a/src/features/dashboard/components/DashboardView.tsx +++ b/src/features/dashboard/components/DashboardView.tsx @@ -1,10 +1,7 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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(() => 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 = ; + else if (dashboardId === 'cluster') page = ; + else if (dashboard) page = ; return ( - {dashboardId === 'overview' ? ( - - ) : dashboardId === 'cluster' ? ( - - ) : dashboard ? ( - - ) : null} + {page} ); } diff --git a/src/features/dashboard/helpers.ts b/src/features/dashboard/helpers.ts index 700dbd2..35f876f 100644 --- a/src/features/dashboard/helpers.ts +++ b/src/features/dashboard/helpers.ts @@ -1,237 +1,172 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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; + +/** 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(); +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(); 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(['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(); + const latest = new Map(); 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(); - for (const { metric, value } of last.values()) { + if (latest.size === 0) return null; + + let total = 0; + let any = false; + const clusterWide = new Map(); + 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 { - const ids = new Set(); - 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 { + 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 { - const ids = new Set(); - 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))); } diff --git a/src/features/dashboard/metrics.test.ts b/src/features/dashboard/metrics.test.ts new file mode 100644 index 0000000..8289124 --- /dev/null +++ b/src/features/dashboard/metrics.test.ts @@ -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); + }); +}); diff --git a/src/features/dashboard/stores/dashboardStore.ts b/src/features/dashboard/stores/dashboardStore.ts index 90b0380..de7cf52 100644 --- a/src/features/dashboard/stores/dashboardStore.ts +++ b/src/features/dashboard/stores/dashboardStore.ts @@ -1,16 +1,15 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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()( 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 }), }, ), ); diff --git a/src/features/dashboard/stores/historyMetricsStore.test.ts b/src/features/dashboard/stores/historyMetricsStore.test.ts new file mode 100644 index 0000000..cedee21 --- /dev/null +++ b/src/features/dashboard/stores/historyMetricsStore.test.ts @@ -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']); + }); +}); diff --git a/src/features/dashboard/stores/historyMetricsStore.ts b/src/features/dashboard/stores/historyMetricsStore.ts index 93592ed..4497228 100644 --- a/src/features/dashboard/stores/historyMetricsStore.ts +++ b/src/features/dashboard/stores/historyMetricsStore.ts @@ -1,19 +1,22 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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) => Promise; invalidate: (dashboardId: string) => void; + /** Fetches again now, still showing the last numbers until the new ones arrive. */ refresh: (dashboardId: string, period: Period, ids: Set) => Promise; } -async function fetchMetrics(period: Period, ids: Set): Promise { +const cacheKey = (dashboardId: string, period: Period) => `${dashboardId}|${periodKey(period)}`; + +async function load(period: Period, ids: Set): Promise { 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 = (map: Map, key: string, value: V) => new Map(map).set(key, value); + +const without = (map: Map, drop: (key: string) => boolean) => + new Map([...map].filter(([key]) => !drop(key))); + export const useHistoryMetricsStore = create()((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); }, diff --git a/src/features/dashboard/stores/liveMetricsStore.test.ts b/src/features/dashboard/stores/liveMetricsStore.test.ts index f447b65..5a52fb7 100644 --- a/src/features/dashboard/stores/liveMetricsStore.test.ts +++ b/src/features/dashboard/stores/liveMetricsStore.test.ts @@ -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> | 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); + }); +}); diff --git a/src/features/dashboard/stores/liveMetricsStore.ts b/src/features/dashboard/stores/liveMetricsStore.ts index becaf57..c7481ce 100644 --- a/src/features/dashboard/stores/liveMetricsStore.ts +++ b/src/features/dashboard/stores/liveMetricsStore.ts @@ -1,13 +1,17 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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) => void; unsubscribe: () => void; - handleBatch: (batch: Metric[]) => void; } -let eventSource: EventSource | null = null; -let reconnectTimer: ReturnType | null = null; -let reconnectAttempts = 0; -let debounceTimer: ReturnType | 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 | undefined; +let reconnectTimer: ReturnType | undefined; +let hiddenTimer: ReturnType | undefined; + +function close() { + stream?.close(); + stream = null; + clearTimeout(reconnectTimer); + reconnectTimer = undefined; } -async function openStream(ids: Set) { - cleanup(); +function liveMetricsUrl(token: string, ids: Set): 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) { + 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 | 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()((set) => ({ snapshot: new Map(), @@ -151,25 +120,13 @@ export const useLiveMetricsStore = create()((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 }; - }); - }, })); diff --git a/src/features/dashboard/types/metrics.ts b/src/features/dashboard/types/metrics.ts index 565b568..88c786e 100644 --- a/src/features/dashboard/types/metrics.ts +++ b/src/features/dashboard/types/metrics.ts @@ -1,29 +1,43 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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 = { - '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 = { + '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 = { - '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; diff --git a/src/features/dashboard/types/schema.ts b/src/features/dashboard/types/schema.ts index 3699d7a..6b0b2ac 100644 --- a/src/features/dashboard/types/schema.ts +++ b/src/features/dashboard/types/schema.ts @@ -1,9 +1,11 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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; diff --git a/src/features/dashboard/zoom.test.ts b/src/features/dashboard/zoom.test.ts index 0577773..84b47f3 100644 --- a/src/features/dashboard/zoom.test.ts +++ b/src/features/dashboard/zoom.test.ts @@ -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); + }); }); diff --git a/src/i18n/en.json b/src/i18n/en.json index db81dbd..30db239 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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",