mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-29 04:47:46 +02:00
feat(site): compare network monitors across systems (#2442)
This commit is contained in:
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user