mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-01 13:57:47 +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(",")
|
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval, server]).join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
function SystemMultiSelect({
|
export function SystemMultiSelect({
|
||||||
id,
|
id,
|
||||||
selectedSystemIds,
|
selectedSystemIds,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
|
systemIds,
|
||||||
|
placeholder,
|
||||||
}: {
|
}: {
|
||||||
id: string
|
id: string
|
||||||
selectedSystemIds: Set<string>
|
selectedSystemIds: Set<string>
|
||||||
onChange: (ids: Set<string>) => void
|
onChange: (ids: Set<string>) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
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 systems = useStore($systems)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
@@ -221,7 +226,9 @@ function SystemMultiSelect({
|
|||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const query = search.trim().toLocaleLowerCase()
|
const query = search.trim().toLocaleLowerCase()
|
||||||
const filteredSystems = systems.filter(
|
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 allSelected = filteredSystems.every((system) => selectedSystemIds.has(system.id))
|
||||||
const anySelected = filteredSystems.some((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" />
|
<ServerIcon className="size-3.5 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedSystemIds.size === 0
|
{selectedSystemIds.size === 0
|
||||||
? t`Select systems`
|
? (placeholder ?? t`Select systems`)
|
||||||
: selectedSystemIds.size === 1
|
: selectedSystemIds.size === 1
|
||||||
? systems.find((s) => selectedSystemIds.has(s.id))?.name
|
? systems.find((s) => selectedSystemIds.has(s.id))?.name
|
||||||
: t`${selectedSystemIds.size} selected`}
|
: t`${selectedSystemIds.size} selected`}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { SystemStatus } from "@/lib/enums"
|
|||||||
import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores"
|
import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores"
|
||||||
import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils"
|
import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils"
|
||||||
import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types"
|
import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types"
|
||||||
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
|
import { AddMonitorDialog, EditMonitorDialog, SystemMultiSelect } from "./monitor-dialog"
|
||||||
import {
|
import {
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowLeftRightIcon,
|
ArrowLeftRightIcon,
|
||||||
@@ -66,8 +66,8 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||||
import { LossChart, AvgMinMaxResponseChart } from "@/components/routes/system/charts/monitors-charts"
|
import { LossChart, AvgMinMaxResponseChart, ResponseChart } from "@/components/routes/system/charts/monitors-charts"
|
||||||
import { useNetworkMonitorStats } from "@/lib/use-network-monitors"
|
import { useMatchingMonitors, useNetworkMonitorStats } from "@/lib/use-network-monitors"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { atom } from "nanostores"
|
import { atom } from "nanostores"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
@@ -701,11 +701,33 @@ function NetworkMonitorSheetContent({
|
|||||||
})
|
})
|
||||||
const chartTime = useStore(chartTimeStore)
|
const chartTime = useStore(chartTimeStore)
|
||||||
const direction = useStore($direction)
|
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({
|
const monitorStats = useNetworkMonitorStats({
|
||||||
systemId: monitor.system,
|
systemId: monitor.system,
|
||||||
monitorId: monitor.id,
|
monitorIds: comparing ? compareMonitors.map((m) => m.id) : [monitor.id],
|
||||||
interval: monitor.interval,
|
interval: monitor.interval,
|
||||||
chartTime,
|
chartTime,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
@@ -719,7 +741,9 @@ function NetworkMonitorSheetContent({
|
|||||||
}),
|
}),
|
||||||
[system?.info?.v, direction, chartTime]
|
[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)
|
const monitorLabel = getMonitorTarget(monitor)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -753,26 +777,63 @@ function NetworkMonitorSheetContent({
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<ChartTimeSelect
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
className="bg-card"
|
<ChartTimeSelect
|
||||||
agentVersion={chartData.agentVersion}
|
className="bg-card flex-1 basis-48"
|
||||||
chartTimeStore={chartTimeStore}
|
agentVersion={chartData.agentVersion}
|
||||||
allowRealtime={false}
|
chartTimeStore={chartTimeStore}
|
||||||
/>
|
allowRealtime={false}
|
||||||
<AvgMinMaxResponseChart
|
/>
|
||||||
monitorStats={monitorStats}
|
{matchingMonitors.length > 0 && (
|
||||||
monitor={monitor}
|
<SystemMultiSelect
|
||||||
chartData={chartData}
|
id="monitor-compare-systems"
|
||||||
empty={!hasMonitorStats}
|
className="w-full sm:w-1/3 shrink-0 bg-card"
|
||||||
/>
|
systemIds={matchingSystemIds}
|
||||||
<LossChart
|
selectedSystemIds={compareSystemIds}
|
||||||
monitorStats={monitorStats}
|
onChange={setCompareSystemIds}
|
||||||
grid={false}
|
placeholder={t`Compare with other systems`}
|
||||||
monitors={[monitor]}
|
/>
|
||||||
chartData={chartData}
|
)}
|
||||||
empty={!hasMonitorStats}
|
</div>
|
||||||
showFilter={false}
|
{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>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type MonitorChartProps = {
|
|||||||
showFilter?: boolean
|
showFilter?: boolean
|
||||||
/** Prepended to the chart title, e.g. a target/system name (rendered as "{titlePrefix} — Response"). */
|
/** Prepended to the chart title, e.g. a target/system name (rendered as "{titlePrefix} — Response"). */
|
||||||
titlePrefix?: string
|
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 & {
|
type MonitorChartBaseProps = MonitorChartProps & {
|
||||||
@@ -44,9 +48,11 @@ function MonitorChart({
|
|||||||
contentFormatter,
|
contentFormatter,
|
||||||
domain,
|
domain,
|
||||||
color,
|
color,
|
||||||
|
getLabel = getMonitorTarget,
|
||||||
|
filterStore = $monitorFilter,
|
||||||
showFilter = monitors.length > 1,
|
showFilter = monitors.length > 1,
|
||||||
}: MonitorChartBaseProps) {
|
}: MonitorChartBaseProps) {
|
||||||
const storedFilter = useStore($monitorFilter)
|
const storedFilter = useStore(filterStore)
|
||||||
const filter = showFilter ? storedFilter : ""
|
const filter = showFilter ? storedFilter : ""
|
||||||
|
|
||||||
const { dataPoints, visibleKeys } = useMemo(() => {
|
const { dataPoints, visibleKeys } = useMemo(() => {
|
||||||
@@ -58,7 +64,7 @@ function MonitorChart({
|
|||||||
const dot = chartData.chartTime === "1m"
|
const dot = chartData.chartTime === "1m"
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const p = sortedMonitors[i]
|
const p = sortedMonitors[i]
|
||||||
const label = getMonitorTarget(p)
|
const label = getLabel(p)
|
||||||
const labelLower = label.toLowerCase()
|
const labelLower = label.toLowerCase()
|
||||||
const filtered = filterGroups.length > 0 && !matchesFilterGroups(labelLower, filterGroups)
|
const filtered = filterGroups.length > 0 && !matchesFilterGroups(labelLower, filterGroups)
|
||||||
if (filtered) {
|
if (filtered) {
|
||||||
@@ -75,7 +81,7 @@ function MonitorChart({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { dataPoints: points, visibleKeys: visibleIDs }
|
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.
|
// 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.
|
// A single monitor's stats already contain empty records at real gaps, so the line breaks there.
|
||||||
@@ -91,7 +97,7 @@ function MonitorChart({
|
|||||||
return (
|
return (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
legend={legend || !showFilter}
|
legend={legend || !showFilter}
|
||||||
cornerEl={showFilter ? <FilterBar store={$monitorFilter} /> : undefined}
|
cornerEl={showFilter ? <FilterBar store={filterStore} /> : undefined}
|
||||||
empty={empty}
|
empty={empty}
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
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 { t } = useLingui()
|
||||||
const lossTitle = t({ message: "Loss", context: "Packet loss" })
|
const lossTitle = t({ message: "Loss", context: "Packet loss" })
|
||||||
const title = titlePrefix ? `${titlePrefix} — ${lossTitle}` : lossTitle
|
const title = titlePrefix ? `${titlePrefix} — ${lossTitle}` : lossTitle
|
||||||
@@ -214,7 +269,11 @@ export function LossChart({ monitorStats, grid, monitors, chartData, empty, titl
|
|||||||
title={title}
|
title={title}
|
||||||
description={t`Packet loss (%)`}
|
description={t`Packet loss (%)`}
|
||||||
domain={[0, 100]}
|
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)}%`}
|
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
if (typeof value !== "number") {
|
if (typeof value !== "number") {
|
||||||
|
|||||||
@@ -62,6 +62,42 @@ export function withMonitorGaps(
|
|||||||
return result
|
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">) {
|
export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" | "protocol" | "port">) {
|
||||||
if (monitor.protocol !== "tcp") return monitor.target
|
if (monitor.protocol !== "tcp") return monitor.target
|
||||||
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
|
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { chartTimeData } from "@/lib/utils"
|
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 {
|
import type {
|
||||||
ChartTimes,
|
ChartTimes,
|
||||||
MonitorStats,
|
MonitorStats,
|
||||||
@@ -15,62 +20,65 @@ import type { RecordListOptions, RecordSubscription } from "pocketbase"
|
|||||||
|
|
||||||
const cache = new Map<string, NetworkMonitorStatsRecord[]>()
|
const cache = new Map<string, NetworkMonitorStatsRecord[]>()
|
||||||
|
|
||||||
function getCacheValue(monitorId: string, chartTime: ChartTimes | "rt") {
|
function getCacheValue(cacheKey: string, chartTime: ChartTimes | "rt") {
|
||||||
return cache.get(`${monitorId}:${chartTime}`) || []
|
return cache.get(`${cacheKey}:${chartTime}`) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendCacheValue(
|
function appendCacheValue(
|
||||||
monitorId: string,
|
cacheKey: string,
|
||||||
chartTime: ChartTimes | "rt",
|
chartTime: ChartTimes | "rt",
|
||||||
newStats: NetworkMonitorStatsRecord[],
|
newStats: NetworkMonitorStatsRecord[],
|
||||||
maxPoints = 100
|
maxPoints = 100
|
||||||
) {
|
) {
|
||||||
const cache_key = `${monitorId}:${chartTime}`
|
const existingStats = getCacheValue(cacheKey, chartTime)
|
||||||
const existingStats = getCacheValue(monitorId, chartTime)
|
const { expectedInterval } = chartTimeData[chartTime]
|
||||||
if (existingStats) {
|
const remaining = mergeSameTimestamps(existingStats, newStats)
|
||||||
const { expectedInterval } = chartTimeData[chartTime]
|
// Copy when records were only merged in place so React still sees a new array.
|
||||||
const updatedStats = appendData(existingStats, newStats, expectedInterval, maxPoints)
|
const base = remaining.length < newStats.length ? existingStats.slice() : existingStats
|
||||||
cache.set(cache_key, updatedStats)
|
const updatedStats = appendData(base, remaining, expectedInterval, maxPoints)
|
||||||
return updatedStats
|
cache.set(`${cacheKey}:${chartTime}`, updatedStats)
|
||||||
} else {
|
return updatedStats
|
||||||
cache.set(cache_key, newStats)
|
|
||||||
return newStats
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge an array of per-monitor raw records into the map-keyed format expected by chart components. */
|
/** Build a `(monitor={:m0} || monitor={:m1} ...)` filter expression and its params. */
|
||||||
export function mergeMonitorStats(rawRecords: RawMonitorStatsRecord[]): NetworkMonitorStatsRecord[] {
|
function monitorIdsFilter(monitorIds: string[]) {
|
||||||
const byTimestamp = new Map<number, Record<string, MonitorStats>>()
|
const params: Record<string, string> = {}
|
||||||
for (const rec of rawRecords) {
|
const expr = monitorIds
|
||||||
let statsMap = byTimestamp.get(rec.created)
|
.map((id, i) => {
|
||||||
if (!statsMap) {
|
params[`m${i}`] = id
|
||||||
statsMap = {}
|
return `monitor={:m${i}}`
|
||||||
byTimestamp.set(rec.created, statsMap)
|
})
|
||||||
}
|
.join(" || ")
|
||||||
statsMap[rec.monitor] = getMonitorStats(rec)
|
return { expr: `(${expr})`, params }
|
||||||
}
|
|
||||||
return Array.from(byTimestamp.entries())
|
|
||||||
.sort(([a], [b]) => a - b)
|
|
||||||
.map(([created, stats]) => ({ created, stats }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
async function fetchMonitorStats(
|
||||||
monitorId: string,
|
monitorIds: string[],
|
||||||
chartTime: ChartTimes,
|
chartTime: ChartTimes,
|
||||||
cached?: NetworkMonitorStatsRecord[]
|
cached?: NetworkMonitorStatsRecord[]
|
||||||
): Promise<NetworkMonitorStatsRecord[]> {
|
): Promise<NetworkMonitorStatsRecord[]> {
|
||||||
const lastCached = cached?.at(-1)?.created as number | undefined
|
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({
|
const rawRecords = await pb.collection<RawMonitorStatsRecord>("network_monitor_stats").getFullList({
|
||||||
filter: pb.filter("monitor={:id} && created>{:created} && type={:type}", {
|
filter: pb.filter(`${expr} && created>{:created} && type={:type}`, {
|
||||||
id: monitorId,
|
...params,
|
||||||
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined, true),
|
created: getPbTimestamp(chartTime, from, true),
|
||||||
type: chartTimeData[chartTime].type,
|
type: chartTimeData[chartTime].type,
|
||||||
}),
|
}),
|
||||||
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created",
|
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created",
|
||||||
sort: "created",
|
sort: "created",
|
||||||
})
|
})
|
||||||
return mergeMonitorStats(rawRecords)
|
return mergeMonitorStats(rawRecords, bucketMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
const NETWORK_MONITOR_FIELDS =
|
const NETWORK_MONITOR_FIELDS =
|
||||||
@@ -156,26 +164,32 @@ export function useNetworkMonitors(props: UseNetworkMonitorsProps) {
|
|||||||
|
|
||||||
interface UseNetworkMonitorStatsProps {
|
interface UseNetworkMonitorStatsProps {
|
||||||
systemId: string
|
systemId: string
|
||||||
monitorId: string
|
/** One monitor, or several (e.g. the same target on different systems) to chart together. */
|
||||||
/** Monitor probe interval in seconds, used to tell missing data apart from slow probes */
|
monitorIds: string[]
|
||||||
|
/** Opened monitor's probe interval in seconds, used to tell missing data apart from slow probes */
|
||||||
interval: number
|
interval: number
|
||||||
chartTime: ChartTimes
|
chartTime: ChartTimes
|
||||||
enabled?: boolean
|
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) {
|
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[]>([])
|
const [monitorStats, setMonitorStats] = useState<NetworkMonitorStatsRecord[]>([])
|
||||||
// pending raw events to be merged (keyed by monitor+created)
|
// pending raw events to be merged (keyed by monitor+created)
|
||||||
const pendingRaw = useRef(new Map<string, RawMonitorStatsRecord>())
|
const pendingRaw = useRef(new Map<string, RawMonitorStatsRecord>())
|
||||||
const mergeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const mergeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMonitorStats(getCacheValue(monitorId, chartTime === "1m" ? "rt" : chartTime))
|
setMonitorStats(getCacheValue(cacheKey, chartTime === "1m" ? "rt" : chartTime))
|
||||||
}, [monitorId, chartTime])
|
}, [cacheKey, chartTime])
|
||||||
|
|
||||||
// Fetch only the selected monitor's missing history.
|
// Fetch only the selected monitors' missing history.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || chartTime === "1m") {
|
if (!enabled || chartTime === "1m") {
|
||||||
return
|
return
|
||||||
@@ -183,7 +197,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const { expectedInterval } = chartTimeData[chartTime]
|
const { expectedInterval } = chartTimeData[chartTime]
|
||||||
const cachedMonitorStats = getCacheValue(monitorId, chartTime)
|
const cachedMonitorStats = getCacheValue(cacheKey, chartTime)
|
||||||
|
|
||||||
if (cachedMonitorStats.length) {
|
if (cachedMonitorStats.length) {
|
||||||
setMonitorStats(cachedMonitorStats)
|
setMonitorStats(cachedMonitorStats)
|
||||||
@@ -193,10 +207,10 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchMonitorStats(monitorId, chartTime, cachedMonitorStats)
|
fetchMonitorStats(cacheKey.split(","), chartTime, cachedMonitorStats)
|
||||||
.then((newMonitorStats) => {
|
.then((newMonitorStats) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setMonitorStats(appendCacheValue(monitorId, chartTime, newMonitorStats))
|
setMonitorStats(appendCacheValue(cacheKey, chartTime, newMonitorStats))
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!cancelled) console.error("Failed to fetch monitor stats:", error)
|
if (!cancelled) console.error("Failed to fetch monitor stats:", error)
|
||||||
@@ -204,7 +218,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [monitorId, chartTime, enabled])
|
}, [cacheKey, chartTime, enabled])
|
||||||
|
|
||||||
// subscribe to new per-monitor stats records; batch them into merged chart records
|
// subscribe to new per-monitor stats records; batch them into merged chart records
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -213,10 +227,13 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
|
const monitorIds = cacheKey.split(",")
|
||||||
|
const bucketMs = getBucketMs(monitorIds, chartTime)
|
||||||
|
const { expr, params } = monitorIdsFilter(monitorIds)
|
||||||
const pbOptions = {
|
const pbOptions = {
|
||||||
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created,type",
|
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created,type",
|
||||||
filter: pb.filter("monitor={:monitor} && type={:type}", {
|
filter: pb.filter(`${expr} && type={:type}`, {
|
||||||
monitor: monitorId,
|
...params,
|
||||||
type: chartTimeData[chartTime].type,
|
type: chartTimeData[chartTime].type,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -225,9 +242,9 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
mergeBatchTimeout.current = null
|
mergeBatchTimeout.current = null
|
||||||
const pending = pendingRaw.current
|
const pending = pendingRaw.current
|
||||||
pendingRaw.current = new Map()
|
pendingRaw.current = new Map()
|
||||||
const merged = mergeMonitorStats(Array.from(pending.values()))
|
const merged = mergeMonitorStats(Array.from(pending.values()), bucketMs)
|
||||||
if (merged.length > 0) {
|
if (merged.length > 0) {
|
||||||
const newStats = appendCacheValue(monitorId, chartTime, merged)
|
const newStats = appendCacheValue(cacheKey, chartTime, merged)
|
||||||
setMonitorStats(newStats)
|
setMonitorStats(newStats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +280,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
pendingRaw.current.clear()
|
pendingRaw.current.clear()
|
||||||
unsubscribe?.()
|
unsubscribe?.()
|
||||||
}
|
}
|
||||||
}, [monitorId, chartTime, enabled])
|
}, [cacheKey, chartTime, enabled])
|
||||||
|
|
||||||
// subscribe to realtime metrics if chart time is 1m
|
// subscribe to realtime metrics if chart time is 1m
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -276,10 +293,15 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
.subscribe(
|
.subscribe(
|
||||||
`rt_metrics`,
|
`rt_metrics`,
|
||||||
(data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => {
|
(data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => {
|
||||||
const monitorStats = data.Monitors?.[monitorId]
|
if (cancelled || !data.Monitors) return
|
||||||
if (cancelled || !monitorStats) return
|
// realtime metrics are per system, so only this system's monitors are available
|
||||||
const stats = { created: Date.now(), stats: { [monitorId]: clearFailedResponse(monitorStats) } }
|
const monitorStats: Record<string, MonitorStats> = {}
|
||||||
const newStats = appendCacheValue(monitorId, "rt", [stats], 120)
|
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)
|
setMonitorStats(newStats)
|
||||||
},
|
},
|
||||||
{ query: { system: systemId } }
|
{ query: { system: systemId } }
|
||||||
@@ -292,12 +314,47 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
unsubscribe?.()
|
unsubscribe?.()
|
||||||
}
|
}
|
||||||
}, [chartTime, systemId, monitorId, enabled])
|
}, [chartTime, systemId, cacheKey, enabled])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(() => {
|
||||||
() => withMonitorGaps(monitorStats, { id: monitorId, interval }, chartTimeData[chartTime].expectedInterval),
|
// Multi-monitor charts connect lines instead (monitors don't share timestamps), so skip gap markers.
|
||||||
[monitorStats, monitorId, interval, chartTime]
|
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) {
|
async function fetchMonitors(system?: string) {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ mock.module("@lingui/core/macro", () => ({
|
|||||||
plural: (_count: number, forms: { other?: string }) => forms.other ?? "",
|
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", () => {
|
describe("monitor stats derived from stored counts", () => {
|
||||||
test("retains probe weights and response precision", () => {
|
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]])
|
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