diff --git a/internal/site/src/components/network-monitors-table/monitor-dialog.tsx b/internal/site/src/components/network-monitors-table/monitor-dialog.tsx index 1bdfd7f78..9e6a5e137 100644 --- a/internal/site/src/components/network-monitors-table/monitor-dialog.tsx +++ b/internal/site/src/components/network-monitors-table/monitor-dialog.tsx @@ -194,18 +194,23 @@ export function formatBulkMonitorLine(monitor: BulkMonitorLineSource) { return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval, server]).join(",") } -function SystemMultiSelect({ +export function SystemMultiSelect({ id, selectedSystemIds, onChange, disabled, className, + systemIds, + placeholder, }: { id: string selectedSystemIds: Set onChange: (ids: Set) => void disabled?: boolean className?: string + /** Limit the options to these systems. Defaults to all systems that support network monitors. */ + systemIds?: string[] + placeholder?: string }) { const systems = useStore($systems) const { t } = useLingui() @@ -221,7 +226,9 @@ function SystemMultiSelect({ const contentRef = useRef(null) const query = search.trim().toLocaleLowerCase() const filteredSystems = systems.filter( - (system) => supportsNetworkMonitors(system) && system.name.toLocaleLowerCase().includes(query) + (system) => + (systemIds ? systemIds.includes(system.id) : supportsNetworkMonitors(system)) && + system.name.toLocaleLowerCase().includes(query) ) const allSelected = filteredSystems.every((system) => selectedSystemIds.has(system.id)) const anySelected = filteredSystems.some((system) => selectedSystemIds.has(system.id)) @@ -247,7 +254,7 @@ function SystemMultiSelect({ {selectedSystemIds.size === 0 - ? t`Select systems` + ? (placeholder ?? t`Select systems`) : selectedSystemIds.size === 1 ? systems.find((s) => selectedSystemIds.has(s.id))?.name : t`${selectedSystemIds.size} selected`} diff --git a/internal/site/src/components/network-monitors-table/network-monitors-table.tsx b/internal/site/src/components/network-monitors-table/network-monitors-table.tsx index 52bf131a6..f88389547 100644 --- a/internal/site/src/components/network-monitors-table/network-monitors-table.tsx +++ b/internal/site/src/components/network-monitors-table/network-monitors-table.tsx @@ -39,7 +39,7 @@ import { SystemStatus } from "@/lib/enums" import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores" import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils" import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types" -import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog" +import { AddMonitorDialog, EditMonitorDialog, SystemMultiSelect } from "./monitor-dialog" import { ArrowDownIcon, ArrowLeftRightIcon, @@ -66,8 +66,8 @@ import { } from "@/components/ui/dropdown-menu" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import ChartTimeSelect from "@/components/charts/chart-time-select" -import { LossChart, AvgMinMaxResponseChart } from "@/components/routes/system/charts/monitors-charts" -import { useNetworkMonitorStats } from "@/lib/use-network-monitors" +import { LossChart, AvgMinMaxResponseChart, ResponseChart } from "@/components/routes/system/charts/monitors-charts" +import { useMatchingMonitors, useNetworkMonitorStats } from "@/lib/use-network-monitors" import { useStore } from "@nanostores/react" import { atom } from "nanostores" import { Separator } from "../ui/separator" @@ -701,11 +701,33 @@ function NetworkMonitorSheetContent({ }) const chartTime = useStore(chartTimeStore) const direction = useStore($direction) - const system = useStore($allSystemsById)[monitor.system] + const systems = useStore($allSystemsById) + const system = systems[monitor.system] + + // Same target probed from other systems, for side-by-side comparison (#2385). + const matchingMonitors = useMatchingMonitors(monitor, open) + const [compareSystemIds, setCompareSystemIds] = useState>(() => new Set()) + // Scoped to this sheet so a filter doesn't carry over to other monitors' sheets. + const [compareFilterStore, setCompareFilterStore] = useState(() => atom("")) + // The sheet is keyed by system (to keep the time range), so reset comparison state per monitor. + const [compareMonitorId, setCompareMonitorId] = useState(monitor.id) + if (compareMonitorId !== monitor.id) { + setCompareMonitorId(monitor.id) + setCompareSystemIds(new Set()) + setCompareFilterStore(atom("")) + } + const matchingSystemIds = useMemo(() => matchingMonitors.map((m) => m.system), [matchingMonitors]) + // The opened system is always charted; the picker only adds other systems to compare against. + const compareMonitors = useMemo( + () => [monitor, ...matchingMonitors.filter((m) => compareSystemIds.has(m.system))], + [monitor, matchingMonitors, compareSystemIds] + ) + const comparing = compareMonitors.length > 1 + const getSystemName = useCallback((m: NetworkMonitorRecord) => systems[m.system]?.name ?? m.system, [systems]) const monitorStats = useNetworkMonitorStats({ systemId: monitor.system, - monitorId: monitor.id, + monitorIds: comparing ? compareMonitors.map((m) => m.id) : [monitor.id], interval: monitor.interval, chartTime, enabled: open, @@ -719,7 +741,9 @@ function NetworkMonitorSheetContent({ }), [system?.info?.v, direction, chartTime] ) - const hasMonitorStats = monitorStats.some((record) => record.stats?.[monitor.id] != null) + const hasMonitorStats = comparing + ? monitorStats.some((record) => record.stats != null) + : monitorStats.some((record) => record.stats?.[monitor.id] != null) const monitorLabel = getMonitorTarget(monitor) return ( @@ -753,26 +777,63 @@ function NetworkMonitorSheetContent({
- - - +
+ + {matchingMonitors.length > 0 && ( + + )} +
+ {comparing ? ( + <> + + + + ) : ( + <> + + + + )}
diff --git a/internal/site/src/components/routes/system/charts/monitors-charts.tsx b/internal/site/src/components/routes/system/charts/monitors-charts.tsx index 287b56faf..7c9961949 100644 --- a/internal/site/src/components/routes/system/charts/monitors-charts.tsx +++ b/internal/site/src/components/routes/system/charts/monitors-charts.tsx @@ -18,6 +18,10 @@ type MonitorChartProps = { showFilter?: boolean /** Prepended to the chart title, e.g. a target/system name (rendered as "{titlePrefix} — Response"). */ titlePrefix?: string + /** Line label for each monitor. Defaults to the monitor target; use the system name when comparing systems. */ + getLabel?: (monitor: NetworkMonitorRecord) => string + /** Filter store for the chart's filter bar. Pass a local atom to keep the filter scoped to one view. */ + filterStore?: typeof $monitorFilter } type MonitorChartBaseProps = MonitorChartProps & { @@ -44,9 +48,11 @@ function MonitorChart({ contentFormatter, domain, color, + getLabel = getMonitorTarget, + filterStore = $monitorFilter, showFilter = monitors.length > 1, }: MonitorChartBaseProps) { - const storedFilter = useStore($monitorFilter) + const storedFilter = useStore(filterStore) const filter = showFilter ? storedFilter : "" const { dataPoints, visibleKeys } = useMemo(() => { @@ -58,7 +64,7 @@ function MonitorChart({ const dot = chartData.chartTime === "1m" for (let i = 0; i < count; i++) { const p = sortedMonitors[i] - const label = getMonitorTarget(p) + const label = getLabel(p) const labelLower = label.toLowerCase() const filtered = filterGroups.length > 0 && !matchesFilterGroups(labelLower, filterGroups) if (filtered) { @@ -75,7 +81,7 @@ function MonitorChart({ }) } return { dataPoints: points, visibleKeys: visibleIDs } - }, [monitors, filter, metric, chartData.chartTime, color]) + }, [monitors, filter, metric, chartData.chartTime, color, getLabel]) // Monitors with different intervals don't share timestamps, so multiple lines need connectNulls. // A single monitor's stats already contain empty records at real gaps, so the line breaks there. @@ -91,7 +97,7 @@ function MonitorChart({ return ( : undefined} + cornerEl={showFilter ? : undefined} empty={empty} title={title} description={description} @@ -198,7 +204,56 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty ) } -export function LossChart({ monitorStats, grid, monitors, chartData, empty, titlePrefix }: MonitorChartProps) { +export function ResponseChart({ + monitorStats, + grid, + monitors, + chartData, + empty, + titlePrefix, + getLabel, + filterStore, + showFilter, +}: MonitorChartProps) { + const { t } = useLingui() + const responseTitle = t`Response` + const title = titlePrefix ? `${titlePrefix} — ${responseTitle}` : responseTitle + + return ( + formatMicroseconds(value, false)} + contentFormatter={({ value }) => { + if (typeof value !== "number") { + return value + } + return formatMicroseconds(value) + }} + /> + ) +} + +export function LossChart({ + monitorStats, + grid, + monitors, + chartData, + empty, + titlePrefix, + getLabel, + filterStore, + showFilter, +}: MonitorChartProps) { const { t } = useLingui() const lossTitle = t({ message: "Loss", context: "Packet loss" }) const title = titlePrefix ? `${titlePrefix} — ${lossTitle}` : lossTitle @@ -214,7 +269,11 @@ export function LossChart({ monitorStats, grid, monitors, chartData, empty, titl title={title} description={t`Packet loss (%)`} domain={[0, 100]} - color="var(--destructive)" + // a single destructive color only makes sense for single-monitor charts + color={monitors.length > 1 ? undefined : "var(--destructive)"} + getLabel={getLabel} + filterStore={filterStore} + showFilter={showFilter} tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`} contentFormatter={({ value }) => { if (typeof value !== "number") { diff --git a/internal/site/src/lib/network-monitor-utils.ts b/internal/site/src/lib/network-monitor-utils.ts index 79f460368..f458af206 100644 --- a/internal/site/src/lib/network-monitor-utils.ts +++ b/internal/site/src/lib/network-monitor-utils.ts @@ -62,6 +62,42 @@ export function withMonitorGaps( return result } +/** + * Fold new records into recent cached records with the same timestamp. Bucketed multi-monitor + * stats arrive per system, so one bucket can be filled by several batches. + * Updates matching cached records in place; returns the records that still need appending. + */ +export function mergeSameTimestamps(existing: NetworkMonitorStatsRecord[], newStats: NetworkMonitorStatsRecord[]) { + if (!existing.length) return newStats + const recent = existing.slice(-10) + return newStats.filter((record) => { + const match = recent.find((r) => r.created !== null && r.created === record.created) + if (!match) return true + match.stats = { ...match.stats, ...record.stats } + return false + }) +} + +/** + * Merge an array of per-monitor raw records into the map-keyed format expected by chart components. + * `bucketMs` floors timestamps so records from different systems (which are not aligned) share a row. + */ +export function mergeMonitorStats(rawRecords: RawMonitorStatsRecord[], bucketMs = 0): NetworkMonitorStatsRecord[] { + const byTimestamp = new Map>() + for (const rec of rawRecords) { + const created = bucketMs > 0 ? Math.floor(rec.created / bucketMs) * bucketMs : rec.created + let statsMap = byTimestamp.get(created) + if (!statsMap) { + statsMap = {} + byTimestamp.set(created, statsMap) + } + statsMap[rec.monitor] = getMonitorStats(rec) + } + return Array.from(byTimestamp.entries()) + .sort(([a], [b]) => a - b) + .map(([created, stats]) => ({ created, stats })) +} + export function getMonitorTarget(monitor: Pick) { if (monitor.protocol !== "tcp") return monitor.target const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target diff --git a/internal/site/src/lib/use-network-monitors.ts b/internal/site/src/lib/use-network-monitors.ts index 747a739e3..5be5c51f4 100644 --- a/internal/site/src/lib/use-network-monitors.ts +++ b/internal/site/src/lib/use-network-monitors.ts @@ -1,5 +1,10 @@ import { chartTimeData } from "@/lib/utils" -import { clearFailedResponse, getMonitorStats, withMonitorGaps } from "@/lib/network-monitor-utils" +import { + clearFailedResponse, + mergeMonitorStats, + mergeSameTimestamps, + withMonitorGaps, +} from "@/lib/network-monitor-utils" import type { ChartTimes, MonitorStats, @@ -15,62 +20,65 @@ import type { RecordListOptions, RecordSubscription } from "pocketbase" const cache = new Map() -function getCacheValue(monitorId: string, chartTime: ChartTimes | "rt") { - return cache.get(`${monitorId}:${chartTime}`) || [] +function getCacheValue(cacheKey: string, chartTime: ChartTimes | "rt") { + return cache.get(`${cacheKey}:${chartTime}`) || [] } function appendCacheValue( - monitorId: string, + cacheKey: string, chartTime: ChartTimes | "rt", newStats: NetworkMonitorStatsRecord[], maxPoints = 100 ) { - const cache_key = `${monitorId}:${chartTime}` - const existingStats = getCacheValue(monitorId, chartTime) - if (existingStats) { - const { expectedInterval } = chartTimeData[chartTime] - const updatedStats = appendData(existingStats, newStats, expectedInterval, maxPoints) - cache.set(cache_key, updatedStats) - return updatedStats - } else { - cache.set(cache_key, newStats) - return newStats - } + const existingStats = getCacheValue(cacheKey, chartTime) + const { expectedInterval } = chartTimeData[chartTime] + const remaining = mergeSameTimestamps(existingStats, newStats) + // Copy when records were only merged in place so React still sees a new array. + const base = remaining.length < newStats.length ? existingStats.slice() : existingStats + const updatedStats = appendData(base, remaining, expectedInterval, maxPoints) + cache.set(`${cacheKey}:${chartTime}`, updatedStats) + return updatedStats } -/** Merge an array of per-monitor raw records into the map-keyed format expected by chart components. */ -export function mergeMonitorStats(rawRecords: RawMonitorStatsRecord[]): NetworkMonitorStatsRecord[] { - const byTimestamp = new Map>() - for (const rec of rawRecords) { - let statsMap = byTimestamp.get(rec.created) - if (!statsMap) { - statsMap = {} - byTimestamp.set(rec.created, statsMap) - } - statsMap[rec.monitor] = getMonitorStats(rec) - } - return Array.from(byTimestamp.entries()) - .sort(([a], [b]) => a - b) - .map(([created, stats]) => ({ created, stats })) +/** Build a `(monitor={:m0} || monitor={:m1} ...)` filter expression and its params. */ +function monitorIdsFilter(monitorIds: string[]) { + const params: Record = {} + const expr = monitorIds + .map((id, i) => { + params[`m${i}`] = id + return `monitor={:m${i}}` + }) + .join(" || ") + return { expr: `(${expr})`, params } } -/** Fetch stats for one monitor and time range, returning merged chart records. */ +/** Raw 1m records are timestamped by each system, so align them when comparing monitors across systems. */ +function getBucketMs(monitorIds: string[], chartTime: ChartTimes) { + const { type, expectedInterval } = chartTimeData[chartTime] + return monitorIds.length > 1 && type === "1m" ? expectedInterval : 0 +} + +/** Fetch stats for one or more monitors and a time range, returning merged chart records. */ async function fetchMonitorStats( - monitorId: string, + monitorIds: string[], chartTime: ChartTimes, cached?: NetworkMonitorStatsRecord[] ): Promise { const lastCached = cached?.at(-1)?.created as number | undefined + const bucketMs = getBucketMs(monitorIds, chartTime) + // Bucketed timestamps are floored, so refetch the whole last bucket; mergeSameTimestamps folds in the overlap. + const from = lastCached ? new Date(bucketMs ? lastCached - 1 : lastCached + 1000) : undefined + const { expr, params } = monitorIdsFilter(monitorIds) const rawRecords = await pb.collection("network_monitor_stats").getFullList({ - filter: pb.filter("monitor={:id} && created>{:created} && type={:type}", { - id: monitorId, - created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined, true), + filter: pb.filter(`${expr} && created>{:created} && type={:type}`, { + ...params, + created: getPbTimestamp(chartTime, from, true), type: chartTimeData[chartTime].type, }), fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created", sort: "created", }) - return mergeMonitorStats(rawRecords) + return mergeMonitorStats(rawRecords, bucketMs) } const NETWORK_MONITOR_FIELDS = @@ -156,26 +164,32 @@ export function useNetworkMonitors(props: UseNetworkMonitorsProps) { interface UseNetworkMonitorStatsProps { systemId: string - monitorId: string - /** Monitor probe interval in seconds, used to tell missing data apart from slow probes */ + /** One monitor, or several (e.g. the same target on different systems) to chart together. */ + monitorIds: string[] + /** Opened monitor's probe interval in seconds, used to tell missing data apart from slow probes */ interval: number chartTime: ChartTimes enabled?: boolean } -/** Returns the monitor's stats with empty records inserted where data is missing (see withMonitorGaps). */ +/** + * Returns stats for the given monitors. A single monitor's stats get empty records inserted where + * data is missing (see withMonitorGaps). + */ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { - const { systemId, monitorId, interval, chartTime, enabled = true } = props + const { systemId, interval, chartTime, enabled = true } = props + // Stable key so effects don't re-run when callers pass a new array with the same IDs. + const cacheKey = [...props.monitorIds].sort().join(",") const [monitorStats, setMonitorStats] = useState([]) // pending raw events to be merged (keyed by monitor+created) const pendingRaw = useRef(new Map()) const mergeBatchTimeout = useRef | null>(null) useEffect(() => { - setMonitorStats(getCacheValue(monitorId, chartTime === "1m" ? "rt" : chartTime)) - }, [monitorId, chartTime]) + setMonitorStats(getCacheValue(cacheKey, chartTime === "1m" ? "rt" : chartTime)) + }, [cacheKey, chartTime]) - // Fetch only the selected monitor's missing history. + // Fetch only the selected monitors' missing history. useEffect(() => { if (!enabled || chartTime === "1m") { return @@ -183,7 +197,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { let cancelled = false const { expectedInterval } = chartTimeData[chartTime] - const cachedMonitorStats = getCacheValue(monitorId, chartTime) + const cachedMonitorStats = getCacheValue(cacheKey, chartTime) if (cachedMonitorStats.length) { setMonitorStats(cachedMonitorStats) @@ -193,10 +207,10 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { } } - fetchMonitorStats(monitorId, chartTime, cachedMonitorStats) + fetchMonitorStats(cacheKey.split(","), chartTime, cachedMonitorStats) .then((newMonitorStats) => { if (cancelled) return - setMonitorStats(appendCacheValue(monitorId, chartTime, newMonitorStats)) + setMonitorStats(appendCacheValue(cacheKey, chartTime, newMonitorStats)) }) .catch((error) => { if (!cancelled) console.error("Failed to fetch monitor stats:", error) @@ -204,7 +218,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { return () => { cancelled = true } - }, [monitorId, chartTime, enabled]) + }, [cacheKey, chartTime, enabled]) // subscribe to new per-monitor stats records; batch them into merged chart records useEffect(() => { @@ -213,10 +227,13 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { } let cancelled = false let unsubscribe: (() => void) | undefined + const monitorIds = cacheKey.split(",") + const bucketMs = getBucketMs(monitorIds, chartTime) + const { expr, params } = monitorIdsFilter(monitorIds) const pbOptions = { fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created,type", - filter: pb.filter("monitor={:monitor} && type={:type}", { - monitor: monitorId, + filter: pb.filter(`${expr} && type={:type}`, { + ...params, type: chartTimeData[chartTime].type, }), } @@ -225,9 +242,9 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { mergeBatchTimeout.current = null const pending = pendingRaw.current pendingRaw.current = new Map() - const merged = mergeMonitorStats(Array.from(pending.values())) + const merged = mergeMonitorStats(Array.from(pending.values()), bucketMs) if (merged.length > 0) { - const newStats = appendCacheValue(monitorId, chartTime, merged) + const newStats = appendCacheValue(cacheKey, chartTime, merged) setMonitorStats(newStats) } } @@ -263,7 +280,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { pendingRaw.current.clear() unsubscribe?.() } - }, [monitorId, chartTime, enabled]) + }, [cacheKey, chartTime, enabled]) // subscribe to realtime metrics if chart time is 1m useEffect(() => { @@ -276,10 +293,15 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { .subscribe( `rt_metrics`, (data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => { - const monitorStats = data.Monitors?.[monitorId] - if (cancelled || !monitorStats) return - const stats = { created: Date.now(), stats: { [monitorId]: clearFailedResponse(monitorStats) } } - const newStats = appendCacheValue(monitorId, "rt", [stats], 120) + if (cancelled || !data.Monitors) return + // realtime metrics are per system, so only this system's monitors are available + const monitorStats: Record = {} + for (const id of cacheKey.split(",")) { + if (data.Monitors[id]) monitorStats[id] = clearFailedResponse(data.Monitors[id]) + } + if (!Object.keys(monitorStats).length) return + const stats = { created: Date.now(), stats: monitorStats } + const newStats = appendCacheValue(cacheKey, "rt", [stats], 120) setMonitorStats(newStats) }, { query: { system: systemId } } @@ -292,12 +314,47 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) { cancelled = true unsubscribe?.() } - }, [chartTime, systemId, monitorId, enabled]) + }, [chartTime, systemId, cacheKey, enabled]) - return useMemo( - () => withMonitorGaps(monitorStats, { id: monitorId, interval }, chartTimeData[chartTime].expectedInterval), - [monitorStats, monitorId, interval, chartTime] - ) + return useMemo(() => { + // Multi-monitor charts connect lines instead (monitors don't share timestamps), so skip gap markers. + if (cacheKey.includes(",")) return monitorStats + return withMonitorGaps(monitorStats, { id: cacheKey, interval }, chartTimeData[chartTime].expectedInterval) + }, [monitorStats, cacheKey, interval, chartTime]) +} + +/** + * Monitors on other systems that probe the same target (same protocol, target, port, and DNS server). + * Fetched once per open so it also works in single-system tables, which only hold one system's monitors. + */ +export function useMatchingMonitors(monitor: NetworkMonitorRecord, enabled = true) { + const [matches, setMatches] = useState([]) + const { id, system, protocol, target, port, server } = monitor + + useEffect(() => { + setMatches([]) + if (!enabled) return + let cancelled = false + pb.collection("network_monitors") + .getFullList({ + fields: NETWORK_MONITOR_FIELDS, + filter: pb.filter( + "id!={:id} && system!={:system} && protocol={:protocol} && target={:target} && port={:port} && server={:server}", + { id, system, protocol, target, port, server } + ), + }) + .then((records) => { + if (!cancelled) setMatches(records) + }) + .catch((error) => { + if (!cancelled) console.error("Failed to fetch matching monitors:", error) + }) + return () => { + cancelled = true + } + }, [id, system, protocol, target, port, server, enabled]) + + return matches } async function fetchMonitors(system?: string) { diff --git a/internal/site/tests/network-monitor-utils.test.ts b/internal/site/tests/network-monitor-utils.test.ts index 46fa7cbcc..16c69aeae 100644 --- a/internal/site/tests/network-monitor-utils.test.ts +++ b/internal/site/tests/network-monitor-utils.test.ts @@ -5,7 +5,9 @@ mock.module("@lingui/core/macro", () => ({ plural: (_count: number, forms: { other?: string }) => forms.other ?? "", })) -const { getMonitorStats, withMonitorGaps } = await import("../src/lib/network-monitor-utils") +const { getMonitorStats, mergeMonitorStats, mergeSameTimestamps, withMonitorGaps } = await import( + "../src/lib/network-monitor-utils" +) describe("monitor stats derived from stored counts", () => { test("retains probe weights and response precision", () => { @@ -87,3 +89,38 @@ describe("monitor gaps", () => { expect(withMonitorGaps(records, monitor, 60_000)).toEqual([records[0], records[3]]) }) }) + +const raw = (monitor: string, created: number, res_sum = 10) => ({ + monitor, + created, + res_min: 1, + res_max: 20, + total_count: 1, + success_count: 1, + res_sum, +}) + +describe("merging stats across monitors", () => { + test("keeps exact timestamps without bucketing", () => { + const merged = mergeMonitorStats([raw("a", 60_100), raw("b", 60_400)]) + expect(merged.map((r) => r.created)).toEqual([60_100, 60_400]) + }) + + test("aligns unaligned system timestamps into one row per bucket", () => { + const merged = mergeMonitorStats([raw("b", 120_900, 30), raw("a", 60_100), raw("b", 60_400, 20)], 60_000) + expect(merged).toHaveLength(2) + expect(merged[0].created).toBe(60_000) + expect(Object.keys(merged[0].stats).sort()).toEqual(["a", "b"]) + expect(merged[0].stats.b.res_avg).toBe(20) + expect(merged[1].created).toBe(120_000) + expect(merged[1].stats.b.res_avg).toBe(30) + }) + + test("folds a late system's record into an existing cached bucket", () => { + const existing = mergeMonitorStats([raw("a", 60_100)], 60_000) + const late = mergeMonitorStats([raw("b", 60_700), raw("b", 120_200)], 60_000) + const remaining = mergeSameTimestamps(existing, late) + expect(Object.keys(existing[0].stats).sort()).toEqual(["a", "b"]) + expect(remaining.map((r) => r.created)).toEqual([120_000]) + }) +})