mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-02 22:37: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(",")
|
||||
}
|
||||
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user