feat(site): compare network monitors across systems (#2442)

This commit is contained in:
Sven van Ginkel
2026-09-28 23:45:54 +02:00
committed by GitHub
parent 7626e5e3c8
commit 666529e073
6 changed files with 353 additions and 96 deletions

View File

@@ -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<string>
onChange: (ids: Set<string>) => 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<HTMLDivElement>(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({
<ServerIcon className="size-3.5 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<span className="truncate">
{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`}

View File

@@ -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<Set<string>>(() => 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({
</SheetDescription>
</SheetHeader>
<div className="grid gap-4">
<ChartTimeSelect
className="bg-card"
agentVersion={chartData.agentVersion}
chartTimeStore={chartTimeStore}
allowRealtime={false}
/>
<AvgMinMaxResponseChart
monitorStats={monitorStats}
monitor={monitor}
chartData={chartData}
empty={!hasMonitorStats}
/>
<LossChart
monitorStats={monitorStats}
grid={false}
monitors={[monitor]}
chartData={chartData}
empty={!hasMonitorStats}
showFilter={false}
/>
<div className="flex flex-wrap items-center gap-2">
<ChartTimeSelect
className="bg-card flex-1 basis-48"
agentVersion={chartData.agentVersion}
chartTimeStore={chartTimeStore}
allowRealtime={false}
/>
{matchingMonitors.length > 0 && (
<SystemMultiSelect
id="monitor-compare-systems"
className="w-full sm:w-1/3 shrink-0 bg-card"
systemIds={matchingSystemIds}
selectedSystemIds={compareSystemIds}
onChange={setCompareSystemIds}
placeholder={t`Compare with other systems`}
/>
)}
</div>
{comparing ? (
<>
<ResponseChart
monitorStats={monitorStats}
grid={false}
monitors={compareMonitors}
chartData={chartData}
empty={!hasMonitorStats}
getLabel={getSystemName}
filterStore={compareFilterStore}
/>
<LossChart
monitorStats={monitorStats}
grid={false}
monitors={compareMonitors}
chartData={chartData}
empty={!hasMonitorStats}
getLabel={getSystemName}
filterStore={compareFilterStore}
/>
</>
) : (
<>
<AvgMinMaxResponseChart
monitorStats={monitorStats}
monitor={monitor}
chartData={chartData}
empty={!hasMonitorStats}
/>
<LossChart
monitorStats={monitorStats}
grid={false}
monitors={[monitor]}
chartData={chartData}
empty={!hasMonitorStats}
showFilter={false}
/>
</>
)}
</div>
</SheetContent>
</Sheet>

View File

@@ -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 (
<ChartCard
legend={legend || !showFilter}
cornerEl={showFilter ? <FilterBar store={$monitorFilter} /> : undefined}
cornerEl={showFilter ? <FilterBar store={filterStore} /> : 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 (
<MonitorChart
monitorStats={monitorStats}
grid={grid}
monitors={monitors}
chartData={chartData}
empty={empty}
metric="res_avg"
title={title}
description={t`Average response time`}
getLabel={getLabel}
filterStore={filterStore}
showFilter={showFilter}
tickFormatter={(value) => 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") {

View File

@@ -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<number, Record<string, MonitorStats>>()
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<NetworkMonitorRecord, "target" | "protocol" | "port">) {
if (monitor.protocol !== "tcp") return monitor.target
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target

View File

@@ -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<string, NetworkMonitorStatsRecord[]>()
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<number, Record<string, MonitorStats>>()
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<string, string> = {}
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<NetworkMonitorStatsRecord[]> {
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<RawMonitorStatsRecord>("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<NetworkMonitorStatsRecord[]>([])
// pending raw events to be merged (keyed by monitor+created)
const pendingRaw = useRef(new Map<string, RawMonitorStatsRecord>())
const mergeBatchTimeout = useRef<ReturnType<typeof setTimeout> | 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<string, MonitorStats> = {}
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<NetworkMonitorRecord[]>([])
const { id, system, protocol, target, port, server } = monitor
useEffect(() => {
setMatches([])
if (!enabled) return
let cancelled = false
pb.collection<NetworkMonitorRecord>("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) {

View File

@@ -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])
})
})