feat: network monitoring from agents (#2266, #1911)

Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
Co-authored-by: xiaomiku01 <xiaomiku01@outlook.com>
This commit is contained in:
hank
2026-09-18 13:22:50 -04:00
committed by GitHub
parent 4bf70700f2
commit bb1b39928e
89 changed files with 8699 additions and 457 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.19.0",
"version": "0.20.0",
"type": "module",
"scripts": {
"dev": "vite --host",
@@ -74,4 +74,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}
}

View File

@@ -61,6 +61,8 @@ export const ActiveAlerts = () => {
<AlertDescription>
{info.triggeredDesc ? (
info.triggeredDesc()
) : alert.name === "NetworkMonitorLoss" ? (
<Trans>One or more monitors exceed {alert.value}% loss</Trans>
) : alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : info.invert ? (

View File

@@ -30,7 +30,8 @@ export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
accessorFn: (record) => {
const name = record.name
const info = alertInfo[name]
return info?.name().replace("cpu", "CPU") || name
const label = info?.name().replace("cpu", "CPU") || name
return record.monitor_name ? `${label}: ${record.monitor_name}` : label
},
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>

View File

@@ -239,13 +239,13 @@ export function AlertContent({
/** Alerts that fire on first observation have no duration to configure */
const noDuration = alertData.noDuration === true
/** Binary alerts have no threshold to configure */
const noThreshold = !!singleDescription || noDuration
const noThreshold = !!singleDescription || alertData.noThreshold === true
/** Whether enabling the alert reveals anything to configure */
const hasControls = !(noThreshold && noDuration)
const [checked, setChecked] = useState(global ? false : !!alert)
const [min, setMin] = useState(alert?.min || (noDuration ? 0 : 10))
const [value, setValue] = useState(alert?.value || (noThreshold ? 0 : (alertData.start ?? 80)))
const [value, setValue] = useState(alert?.value ?? (noThreshold ? 0 : (alertData.start ?? 80)))
const Icon = alertData.icon
@@ -319,7 +319,7 @@ export function AlertContent({
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}>
{!noThreshold && (
<div>
<div className={cn(noDuration && "col-span-full")}>
<p id={`v${name}`} className="text-sm block h-6">
{alertData.invert ? (
<Trans>

View File

@@ -66,7 +66,7 @@ export default function AreaChartDefault({
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats
const sourceData = customData ?? chartData.systemStats ?? []
const [displayData, setDisplayData] = useState(sourceData)
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
@@ -111,6 +111,8 @@ export default function AreaChartDefault({
})
}, [areasKey, displayMaxToggled])
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
return useMemo(() => {
if (displayData.length === 0) {
return null
@@ -146,7 +148,7 @@ export default function AreaChartDefault({
axisLine={false}
/>
)}
{xAxis(chartData)}
{XAxis}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
@@ -167,5 +169,5 @@ export default function AreaChartDefault({
</AreaChart>
</ChartContainer>
)
}, [displayData, yAxisWidth, filter, Areas])
}, [displayData, yAxisWidth, filter, Areas, XAxis])
}

View File

@@ -9,14 +9,21 @@ import { memo } from "react"
export default memo(function ChartTimeSelect({
className,
agentVersion,
chartTimeStore = $chartTime,
allowRealtime = true,
}: {
className?: string
agentVersion: SemVer
chartTimeStore?: typeof $chartTime
allowRealtime?: boolean
}) {
const chartTime = useStore($chartTime)
const chartTime = useStore(chartTimeStore)
// remove chart times that are not supported by the system agent version
const availableChartTimes = Object.entries(chartTimeData).filter(([_, { minVersion }]) => {
const availableChartTimes = Object.entries(chartTimeData).filter(([value, { minVersion }]) => {
if (value === "1m" && !allowRealtime) {
return false
}
if (!minVersion) {
return true
}
@@ -24,7 +31,7 @@ export default memo(function ChartTimeSelect({
})
return (
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => chartTimeStore.set(value)}>
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
<HistoryIcon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />

View File

@@ -22,6 +22,10 @@ export type DataPoint<T = SystemStatsRecord> = {
order?: number
strokeOpacity?: number
activeDot?: boolean
dot?: boolean
/** Which Y axis this series plots against. Defaults to "left". */
yAxisId?: "left" | "right"
strokeDasharray?: string
}
export default function LineChartDefault({
@@ -30,9 +34,12 @@ export default function LineChartDefault({
max,
maxToggled,
tickFormatter,
tickFormatter2,
contentFormatter,
dataPoints,
domain,
domain2,
max2,
legend,
itemSorter,
showTotal = false,
@@ -41,18 +48,24 @@ export default function LineChartDefault({
filter,
truncate = false,
chartProps,
connectNulls,
}: {
chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
customData?: any[]
max?: number
max2?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
/** Tick formatter for the right ("right"-yAxisId) axis, when any dataPoint uses it. */
tickFormatter2?: (value: number, index: number) => string
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
contentFormatter: (item: any, key: string) => ReactNode
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
dataPoints?: DataPoint<any>[]
domain?: AxisDomain
/** Domain for the right axis, when any dataPoint uses it. */
domain2?: AxisDomain
legend?: boolean
showTotal?: boolean
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
@@ -62,10 +75,15 @@ export default function LineChartDefault({
filter?: string
truncate?: boolean
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
connectNulls?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const hasRightAxis = !!dataPoints?.some((dp) => dp.yAxisId === "right")
// fixed width for the secondary axis rather than measured, since its labels (e.g. loss %) are short
// and predictable, and this avoids depending on a second async width-measurement pass to settle
const rightAxisWidth = 38
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats
const sourceData = customData ?? chartData.systemStats ?? []
const [displayData, setDisplayData] = useState(sourceData)
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
@@ -83,7 +101,9 @@ export default function LineChartDefault({
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
// Use a stable key derived from data point identities and visual properties
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity}${d.dot}${d.yAxisId}${d.strokeDasharray}`).join("\0")
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
const Lines = useMemo(() => {
return dataPoints?.map((dataPoint, i) => {
@@ -94,17 +114,20 @@ export default function LineChartDefault({
return (
<Line
key={dataPoint.label}
yAxisId={dataPoint.yAxisId ?? "left"}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
dot={dataPoint.dot || false}
strokeWidth={1.5}
stroke={color}
strokeOpacity={dataPoint.strokeOpacity}
strokeDasharray={dataPoint.strokeDasharray}
isAnimationActive={false}
// stackId={dataPoint.stackId}
order={dataPoint.order || i}
activeDot={dataPoint.activeDot ?? true}
connectNulls={connectNulls}
/>
)
})
@@ -135,6 +158,7 @@ export default function LineChartDefault({
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
yAxisId="left"
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
@@ -145,7 +169,20 @@ export default function LineChartDefault({
axisLine={false}
/>
)}
{xAxis(chartData)}
{!hideYAxis && hasRightAxis && (
<YAxis
yAxisId="right"
direction="ltr"
orientation={chartData.orientation === "left" ? "right" : "left"}
className="tracking-tighter"
width={rightAxisWidth}
domain={domain2 ?? [0, max2 ?? "auto"]}
tickFormatter={tickFormatter2 ?? tickFormatter}
tickLine={false}
axisLine={false}
/>
)}
{XAxis}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
@@ -166,5 +203,5 @@ export default function LineChartDefault({
</LineChart>
</ChartContainer>
)
}, [displayData, yAxisWidth, filter, Lines])
}, [displayData, yAxisWidth, hasRightAxis, filter, Lines, XAxis])
}

View File

@@ -11,6 +11,7 @@ import {
HardDriveIcon,
LogsIcon,
MailIcon,
NetworkIcon,
Server,
ServerIcon,
SettingsIcon,
@@ -122,6 +123,20 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "monitors"))
setOpen(false)
}}
>
<NetworkIcon className="me-2 size-4" />
<span>
<Trans>Network Monitors</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "general" }))

View File

@@ -16,7 +16,7 @@ import {
import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons"
import { Badge } from "../ui/badge"
import { t } from "@lingui/core/macro"
import { $allSystemsById, $longestSystemNameLen } from "@/lib/stores"
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
@@ -64,10 +64,13 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
const longestName = useStore($longestSystemNameLen)
const longestName = useStore($longestSystemName)
return (
<div className="ms-1 max-w-40 truncate" style={{ width: `${longestName / 1.05}ch` }}>
{allSystems[getValue() as string]?.name ?? ""}
<div className="ms-1 relative w-fit max-w-40">
<span className="invisible block whitespace-nowrap" aria-hidden="true">
{longestName}
</span>
<span className="absolute inset-0 truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
</div>
)
},

View File

@@ -8,6 +8,7 @@ import {
LogOutIcon,
LogsIcon,
MenuIcon,
NetworkIcon,
PlusIcon,
SearchIcon,
ServerIcon,
@@ -108,6 +109,13 @@ export default function Navbar() {
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<span>S.M.A.R.T.</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate(getPagePath($router, "monitors"))}
className="flex items-center"
>
<NetworkIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<Trans>Network Monitors</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
className="flex items-center"
@@ -179,6 +187,21 @@ export default function Navbar() {
</TooltipTrigger>
<TooltipContent>S.M.A.R.T.</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "monitors")}
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Network Monitors"
onMouseEnter={() => import("@/components/routes/monitors")}
>
<NetworkIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans>Network Monitors</Trans>
</TooltipContent>
</Tooltip>
<ModeToggle />
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -0,0 +1,778 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { pb } from "@/lib/api"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { ChevronDownIcon, ListIcon, SearchIcon, ServerIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores"
import { cn, supportsNetworkMonitors } from "@/lib/utils"
import type { NetworkMonitorRecord } from "@/types"
import * as v from "valibot"
type MonitorProtocol = "icmp" | "tcp" | "http" | "dns"
type MonitorValues = {
system: string
target: string
protocol: MonitorProtocol
port: number
interval: string
}
type NormalizedMonitorValues = Omit<MonitorValues, "system" | "interval"> & {
interval: number
}
type BulkMonitorLineSource = Pick<NetworkMonitorRecord, "target" | "protocol" | "port" | "interval">
const defaultInterval = 30
const MonitorProtocolSchema = v.picklist(["icmp", "tcp", "http", "dns"])
const MonitorIntervalSchema = v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600))
// Both the single-monitor form and the bulk importer flow through this schema so
// defaults and HTTP target normalization stay in one place.
const NormalizedMonitorValuesSchema = v.pipe(
v.object({
target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")),
protocol: MonitorProtocolSchema,
port: v.number(),
interval: MonitorIntervalSchema,
}),
v.transform((input): NormalizedMonitorValues => {
let { protocol, port } = input
let httpTarget = input.target
if (protocol === "icmp" || protocol === "http" || protocol === "dns") {
if (protocol === "http") {
httpTarget = normalizeHttpTarget(input.target, port)
}
port = 0
} else if (protocol === "tcp" && !port) {
port = 443
}
return {
// HTTP monitors may be entered as bare hostnames, so normalize them to a
// scheme-bearing URL before the payload is sent to PocketBase.
target: protocol === "http" ? httpTarget : input.target,
protocol,
port,
interval: input.interval,
}
}),
v.forward(
v.check((input) => {
if (input.protocol === "icmp" || input.protocol === "http" || input.protocol === "dns") {
return input.port === 0
}
return Number.isInteger(input.port) && input.port >= 1 && input.port <= 65535
}, "Port must be between 1 and 65535"),
["port"]
)
)
// Bulk parsing only trims raw CSV fields. Inference, defaults, and protocol-
// specific validation still go through the shared normalization schema above.
const BulkMonitorSchema = v.object({
target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")),
protocol: v.optional(v.pipe(v.string(), v.trim())),
port: v.optional(v.pipe(v.string(), v.trim())),
interval: v.optional(v.pipe(v.string(), v.trim())),
})
function normalizeHttpTarget(target: string, port = 0) {
const useExplicitPort = port > 0 && port !== 80 && port !== 443
const hasOriginOnlyTarget = /^https?:\/\/[^/?#]+$/i.test(target)
if (!/^https?:\/\//i.test(target)) {
const scheme = port === 80 ? "http" : "https"
return `${scheme}://${target}${useExplicitPort ? `:${port}` : ""}`
}
let parsedUrl: URL
try {
parsedUrl = new URL(target)
} catch {
return target
}
if (!parsedUrl.port && useExplicitPort) {
parsedUrl.port = `${port}`
}
// avoid converting "http://localhost:8090" to "http://localhost:8090/" - keep the original formatting if the URL is just an origin
if (hasOriginOnlyTarget && parsedUrl.pathname === "/" && !parsedUrl.search && !parsedUrl.hash) {
return parsedUrl.origin
}
return parsedUrl.toString()
}
function trimTrailingEmptyFields(fields: string[]) {
let lastValueIndex = fields.length - 1
while (lastValueIndex > 0 && !fields[lastValueIndex]) {
lastValueIndex--
}
return fields.slice(0, lastValueIndex + 1)
}
function buildMonitorPayload(values: MonitorValues, enabled = true) {
const normalizedValues = v.safeParse(NormalizedMonitorValuesSchema, values)
if (!normalizedValues.success) {
throw new Error(normalizedValues.issues[0]?.message || "Invalid monitor")
}
const payload = {
system: values.system,
enabled,
...normalizedValues.output,
}
return payload
}
type MonitorIdentity = Pick<MonitorValues, "system" | "target" | "protocol" | "port">
function getMonitorIdentityKey({ system, target, protocol, port }: MonitorIdentity) {
return `${system}${target}${protocol}${port}`
}
function parseBulkMonitorLine(line: string, lineNumber: number, system: string) {
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = ""] = line.split(",")
const parsed = v.safeParse(BulkMonitorSchema, {
target: rawTarget,
protocol: rawProtocol,
port: rawPort,
interval: rawInterval,
})
if (!parsed.success) {
throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid monitor entry"}`)
}
const protocol = (parsed.output.protocol?.toLowerCase() ||
(/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as MonitorProtocol
return buildMonitorPayload({
system,
target: parsed.output.target,
protocol,
port: parsed.output.port ? Number(parsed.output.port) : 0,
interval: parsed.output.interval || `${defaultInterval}`,
})
}
export function formatBulkMonitorLine(monitor: BulkMonitorLineSource) {
const port = monitor.protocol !== "tcp" || monitor.port === 443 ? "" : `${monitor.port}`
const interval = monitor.interval === defaultInterval ? "" : `${monitor.interval}`
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval]).join(",")
}
function SystemMultiSelect({
id,
selectedSystemIds,
onChange,
disabled,
className,
}: {
id: string
selectedSystemIds: Set<string>
onChange: (ids: Set<string>) => void
disabled?: boolean
className?: string
}) {
const systems = useStore($systems)
const { t } = useLingui()
const [search, setSearch] = useState("")
const searchRef = useRef<HTMLInputElement>(null)
const focusSearchOnMount = useCallback((node: HTMLInputElement | null) => {
searchRef.current = node
if (!node) return
// Focus after the menu has completed its own initial focus handling.
const frame = requestAnimationFrame(() => node.focus())
return () => cancelAnimationFrame(frame)
}, [])
const contentRef = useRef<HTMLDivElement>(null)
const query = search.trim().toLocaleLowerCase()
const filteredSystems = systems.filter(
(system) => 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))
const selectFiltered = (selected: boolean) => {
const next = new Set(selectedSystemIds)
for (const system of filteredSystems) {
if (selected) next.add(system.id)
else next.delete(system.id)
}
onChange(next)
}
return (
<DropdownMenu onOpenChange={() => setSearch("")}>
<DropdownMenuTrigger asChild>
<Button
id={id}
disabled={disabled}
type="button"
variant="outline"
className={cn("relative w-full min-w-0 ps-10 pe-10 justify-start font-normal text-start", className)}
>
<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`
: selectedSystemIds.size === 1
? systems.find((s) => selectedSystemIds.has(s.id))?.name
: t`${selectedSystemIds.size} systems selected`}
</span>
<ChevronDownIcon className="size-4 absolute end-4 top-1/2 -translate-y-1/2 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={contentRef}
onKeyDown={(event) => {
if (event.key === "Tab") {
event.preventDefault()
searchRef.current?.focus()
}
}}
align="start"
className="w-[var(--radix-dropdown-menu-trigger-width)] max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] flex flex-col overflow-hidden"
>
<div className="shrink-0 border-b mb-1">
<div className="flex items-center gap-2 px-2.5">
<SearchIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<Input
ref={focusSearchOnMount}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t`Search systems`}
aria-label={t`Search systems`}
className="h-10 min-w-0 rounded-none border-0 bg-transparent px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onKeyDown={(event) => {
if (event.key === "Escape") return
// Keep menu typeahead and form submission from consuming search input.
event.stopPropagation()
if (event.key === "Enter") event.preventDefault()
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Tab") {
event.preventDefault()
const items = contentRef.current?.querySelectorAll<HTMLElement>(
'[role^="menuitem"]:not([data-disabled])'
)
const index = event.key === "ArrowUp" || event.shiftKey ? (items?.length ?? 1) - 1 : 0
items?.[index]?.focus()
}
}}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-1 pb-1">
<div className="flex items-center">
<DropdownMenuItem
className="px-1.5 py-1 text-xs text-muted-foreground"
disabled={!filteredSystems.length || allSelected}
onSelect={(event) => {
event.preventDefault()
selectFiltered(true)
}}
>
{query ? <Trans>Select matches</Trans> : <Trans>Select all</Trans>}
</DropdownMenuItem>
<span aria-hidden="true" className="text-xs text-muted-foreground/50">
·
</span>
<DropdownMenuItem
className="px-1.5 py-1 text-xs text-muted-foreground"
disabled={!anySelected}
onSelect={(event) => {
event.preventDefault()
selectFiltered(false)
}}
>
{query ? <Trans>Clear matches</Trans> : <Trans>Clear all</Trans>}
</DropdownMenuItem>
</div>
<span className="px-1.5 text-xs tabular-nums text-muted-foreground">
{t`${selectedSystemIds.size} selected`}
</span>
</div>
</div>
<div className="min-h-0 overflow-y-auto">
{filteredSystems.length === 0 && (
<output className="block px-2.5 py-3 text-sm text-muted-foreground">
<Trans>No systems found.</Trans>
</output>
)}
{filteredSystems.map((sys) => (
<DropdownMenuCheckboxItem
key={sys.id}
checked={selectedSystemIds.has(sys.id)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => {
const next = new Set(selectedSystemIds)
if (checked) next.add(sys.id)
else next.delete(sys.id)
onChange(next)
}}
className="group min-w-0 gap-2.5 py-2 ps-2.5"
indicatorClassName="static size-4 shrink-0 rounded border border-input group-data-[state=checked]:border-primary group-data-[state=checked]:bg-primary group-data-[state=checked]:text-primary-foreground [&_svg]:size-3"
>
<span className="truncate">{sys.name}</span>
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; monitors: NetworkMonitorRecord[] }) {
const [open, setOpen] = useState(false)
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkInput, setBulkInput] = useState("")
const [bulkLoading, setBulkLoading] = useState(false)
const [bulkSelectedSystemIds, setBulkSelectedSystemIds] = useState<Set<string>>(new Set())
const bulkFormRef = useRef<HTMLFormElement>(null)
const { toast } = useToast()
const { t } = useLingui()
const resetBulkForm = () => {
setBulkInput("")
}
const openBulkAdd = (selectedSystemIds?: Set<string>) => {
if (!systemId && selectedSystemIds) {
setBulkSelectedSystemIds(new Set(selectedSystemIds))
}
setOpen(false)
setBulkOpen(true)
}
const openAdd = () => {
setBulkOpen(false)
setOpen(true)
}
async function handleBulkSubmit(e: React.FormEvent) {
e.preventDefault()
setBulkLoading(true)
let closedForSubmit = false
try {
const targetSystems = systemId ? [systemId] : Array.from(bulkSelectedSystemIds)
if (!targetSystems.length) {
throw new Error("Select at least one system.")
}
const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim())
if (!rawLines.length) {
throw new Error("Enter at least one monitor.")
}
let totalCreated = 0
closedForSubmit = true
for (const system of targetSystems) {
const payloads = rawLines.map((line, index) => parseBulkMonitorLine(line, index + 1, system))
const existingMonitorKeys = new Set(
monitors.filter((monitor) => monitor.system === system).map((monitor) => getMonitorIdentityKey(monitor))
)
const newPayloads: typeof payloads = []
for (const payload of payloads) {
const monitorKey = getMonitorIdentityKey(payload)
if (existingMonitorKeys.has(monitorKey)) {
continue
}
existingMonitorKeys.add(monitorKey)
newPayloads.push(payload)
}
if (!newPayloads.length) continue
let batch = pb.createBatch()
let inBatch = 0
for (const payload of newPayloads) {
batch.collection("network_monitors").create(payload)
inBatch++
if (inBatch > 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
if (inBatch) {
await batch.send()
}
totalCreated += newPayloads.length
}
if (!totalCreated) {
throw new Error("No new monitors. All entries already exist.")
}
resetBulkForm()
toast({ title: t`Monitors created`, description: `${totalCreated} monitor(s) added.` })
} catch (err: unknown) {
if (closedForSubmit) {
setBulkOpen(true)
}
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
} finally {
setBulkLoading(false)
}
}
return (
<>
<div className="flex gap-0 rounded-lg">
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow">
{/* <PlusIcon className="size-4 me-1" /> */}
<Trans>Add {{ foo: t`Monitor` }}</Trans>
</Button>
<div className="w-px h-full bg-muted"></div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="px-2 rounded-s-none border-s-0" aria-label={t`More monitor actions`}>
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openBulkAdd()}>
<ListIcon className="size-4 me-2" />
<Trans>Bulk Add</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
}}
>
<MonitorDialogContent open={open} setOpen={setOpen} systemId={systemId} onOpenBulkAdd={openBulkAdd} />
</Dialog>
<Sheet
open={bulkOpen}
onOpenChange={(nextOpen) => {
setBulkOpen(nextOpen)
if (!nextOpen) {
resetBulkForm()
}
}}
>
<SheetContent className="w-full sm:max-w-xl gap-0">
<SheetHeader className="border-b">
<SheetTitle>
<Trans>Bulk Add {{ foo: t`Network Monitors` }}</Trans>
</SheetTitle>
<SheetDescription>target[,protocol[,port[,interval]]]</SheetDescription>
</SheetHeader>
<form ref={bulkFormRef} onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
<div className="flex-1 flex flex-col space-y-4 overflow-auto p-4">
{!systemId && (
<div className="grid gap-2">
<Label htmlFor="bulk-monitor-systems" className="sr-only">
<Trans>Systems</Trans>
</Label>
<SystemMultiSelect
id="bulk-monitor-systems"
selectedSystemIds={bulkSelectedSystemIds}
onChange={setBulkSelectedSystemIds}
disabled={bulkLoading}
className="bg-card"
/>
</div>
)}
<div className="grow flex flex-col gap-2">
<Label htmlFor="bulk-monitors" className="sr-only">
Entries
</Label>
<Textarea
id="bulk-monitors"
value={bulkInput}
onChange={(e) => setBulkInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
bulkFormRef.current?.requestSubmit()
}
}}
className="font-mono grow text-sm bg-card"
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60"].join("\n")}
required
/>
<p className="text-xs text-muted-foreground">target[,protocol[,port[,interval]]]</p>
</div>
</div>
<SheetFooter className="border-t">
<Button type="submit" disabled={bulkLoading || (!systemId && !bulkSelectedSystemIds.size)}>
<Trans>Add {{ foo: t`Network Monitors` }}</Trans>
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
</>
)
}
export function EditMonitorDialog({
open,
setOpen,
systemId,
monitor,
}: {
open: boolean
setOpen: (open: boolean) => void
systemId?: string
monitor?: NetworkMonitorRecord
}) {
const hasOpened = useRef(false)
if (!monitor && !hasOpened.current) {
return null
}
hasOpened.current = true
return (
<Dialog open={open} onOpenChange={setOpen}>
<MonitorDialogContent open={open} setOpen={setOpen} systemId={systemId} monitor={monitor} />
</Dialog>
)
}
function MonitorDialogContent({
open,
setOpen,
systemId,
monitor,
onOpenBulkAdd,
}: {
open: boolean
setOpen: (open: boolean) => void
systemId?: string
monitor?: NetworkMonitorRecord
onOpenBulkAdd?: (selectedSystemIds: Set<string>) => void
}) {
const [protocol, setProtocol] = useState<MonitorProtocol>(monitor?.protocol ?? "icmp")
const [target, setTarget] = useState(monitor?.target ?? "")
const [port, setPort] = useState(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
const [monitorInterval, setMonitorInterval] = useState(String(monitor?.interval ?? defaultInterval))
const [loading, setLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState(monitor?.system ?? "")
const [selectedSystemIds, setSelectedSystemIds] = useState<Set<string>>(new Set())
const systems = useStore($systems)
const { toast } = useToast()
const { t } = useLingui()
const isEditing = !!monitor
// When the dialog is opened, initialize form fields with monitor values (if editing) or defaults (if adding).
useEffect(() => {
if (!open) {
return
}
setProtocol(monitor?.protocol ?? "icmp")
setTarget(monitor?.target ?? "")
setPort(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
setMonitorInterval(String(monitor?.interval ?? defaultInterval))
setSelectedSystemId(monitor?.system ?? "")
setSelectedSystemIds(new Set())
setLoading(false)
}, [open, monitor])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
const targetSystems = systemId ? [systemId] : monitor ? [selectedSystemId] : Array.from(selectedSystemIds)
const remainingSystemIds = new Set(targetSystems)
try {
if (!targetSystems.length || !targetSystems[0]) throw new Error("Select at least one system.")
const payload = buildMonitorPayload(
{
system: targetSystems[0],
target,
protocol,
port: protocol === "tcp" ? Number(port) : 0,
interval: monitorInterval,
},
monitor ? monitor.enabled : true
)
if (monitor) {
await pb.collection("network_monitors").update(monitor.id, payload)
} else {
for (const system of targetSystems) {
await pb.collection("network_monitors").create({ ...payload, system })
remainingSystemIds.delete(system)
}
}
setOpen(false)
} catch (err: unknown) {
if (!monitor && !systemId) {
// Retain only unfinished systems so retrying cannot duplicate successful creates.
setSelectedSystemIds(remainingSystemIds)
}
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
} finally {
setLoading(false)
}
}
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isEditing ? (
<Trans>Edit {{ foo: t`Network Monitor` }}</Trans>
) : (
<Trans>Add {{ foo: t`Network Monitor` }}</Trans>
)}
</DialogTitle>
<DialogDescription>
<Trans>Configure response monitoring from this agent.</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
{!systemId && !isEditing && (
<div className="grid gap-2">
<Label htmlFor="monitor-systems">
<Trans>Systems</Trans>
</Label>
<SystemMultiSelect
id="monitor-systems"
selectedSystemIds={selectedSystemIds}
onChange={setSelectedSystemIds}
disabled={loading}
/>
</div>
)}
{!systemId && isEditing && (
<div className="grid gap-2">
<Label>
<Trans>System</Trans>
</Label>
<Select value={selectedSystemId} onValueChange={setSelectedSystemId} required>
<SelectTrigger>
<SelectValue placeholder={t`Select a system`} />
</SelectTrigger>
<SelectContent>
{systems
.filter((sys) => sys.id === monitor?.system || supportsNetworkMonitors(sys))
.map((sys) => (
<SelectItem key={sys.id} value={sys.id}>
{sys.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Target</Trans>
</Label>
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={protocol === "http" ? "http://localhost:8090" : "1.1.1.1"}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Protocol</Trans>
</Label>
<Select value={protocol} onValueChange={(value) => setProtocol(value as MonitorProtocol)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="dns">DNS</SelectItem>
</SelectContent>
</Select>
</div>
{protocol === "tcp" && (
<div className="grid gap-2">
<Label>
<Trans>Port</Trans>
</Label>
<Input
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="443"
min={1}
max={65535}
/>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Interval (seconds)</Trans>
</Label>
<Input
type="number"
value={monitorInterval}
onChange={(e) => setMonitorInterval(e.target.value)}
min={1}
max={3600}
required
/>
</div>
<DialogFooter>
{!isEditing && onOpenBulkAdd && (
<Button
type="button"
variant="outline"
onClick={() => onOpenBulkAdd(selectedSystemIds)}
disabled={loading}
className="me-auto"
>
<ListIcon className="size-4 me-2" />
<Trans>Bulk Add</Trans>
</Button>
)}
<Button
type="submit"
disabled={loading || (!systemId && (isEditing ? !selectedSystemId : !selectedSystemIds.size))}
>
{loading ? (
isEditing ? (
<Trans>Saving...</Trans>
) : (
<Trans>Creating...</Trans>
)
) : isEditing ? (
<Trans>Save {{ foo: t`Monitor` }}</Trans>
) : (
<Trans>Add {{ foo: t`Monitor` }}</Trans>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}

View File

@@ -0,0 +1,411 @@
import type { CellContext, Column, ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { cn, copyToClipboard, decimalString, formatMicroseconds, hourWithSeconds } from "@/lib/utils"
import {
GlobeIcon,
TimerIcon,
WifiOffIcon,
Trash2Icon,
ArrowLeftRightIcon,
MoreHorizontalIcon,
ServerIcon,
ClockIcon,
RefreshCwIcon,
PenBoxIcon,
PauseCircleIcon,
PlayCircleIcon,
CopyIcon,
CopyPlusIcon,
} from "lucide-react"
import { t } from "@lingui/core/macro"
import type { NetworkMonitorRecord, SystemRecord } from "@/types"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Trans } from "@lingui/react/macro"
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { SystemStatus } from "@/lib/enums"
import { Checkbox } from "@/components/ui/checkbox"
import { useMemo } from "react"
import { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog"
import { Badge } from "../ui/badge"
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import { pb } from "@/lib/api"
const protocolColors: Record<string, string> = {
icmp: "bg-blue-500/15! text-blue-600 dark:text-blue-400",
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
http: "bg-green-500/15! text-green-700 dark:text-green-400",
dns: "bg-amber-500/15! text-amber-600 dark:text-amber-400",
}
const SYSTEM_STATUS_COLORS = {
[SystemStatus.Up]: "bg-green-500",
[SystemStatus.Down]: "bg-red-500",
[SystemStatus.Paused]: "bg-primary/40",
[SystemStatus.Pending]: "bg-yellow-500",
} as const
/**
* A monitor is considered muted if it's disabled or if its associated system is not up.
*/
const isMuted = (record: NetworkMonitorRecord, systemRecord: SystemRecord | undefined) =>
!record.enabled || systemRecord?.status !== SystemStatus.Up
export function getMonitorColumns(
longestTarget = "",
{
onEdit,
onDelete,
onSetEnabled,
}: {
onEdit?: (monitor: NetworkMonitorRecord) => void
onDelete?: (monitors: NetworkMonitorRecord[]) => void | Promise<void>
onSetEnabled?: (monitors: NetworkMonitorRecord[], enabled: boolean) => void | Promise<void>
} = {}
): ColumnDef<NetworkMonitorRecord>[] {
return [
{
id: "select",
header: ({ table }) => (
<Checkbox
className="ms-2"
checked={table.getIsAllRowsSelected() || (table.getIsSomeRowsSelected() && "indeterminate")}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label={t`Select all`}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t`Select row`}
/>
),
enableSorting: false,
enableHiding: false,
size: 44,
},
{
id: "system",
accessorFn: (record) => record.system,
sortingFn: (a, b) => {
const allSystems = $allSystemsById.get()
const systemNameA = allSystems[a.original.system]?.name ?? ""
const systemNameB = allSystems[b.original.system]?.name ?? ""
const primary = systemNameA.localeCompare(systemNameB)
if (primary !== 0) {
return primary
}
return a.original.target.localeCompare(b.original.target)
},
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const system = useStore($allSystemsById)[getValue() as string] as SystemRecord | undefined
const longestSystemName = useStore($longestSystemName)
const name = system?.name
const status = system?.status as SystemStatus // undefined val is fine but makes lsp mad
return useMemo(
() => (
<div className="ms-1.5 max-w-44 flex gap-2 items-center tabular-nums">
<span className={cn("shrink-0 size-2 rounded-full", SYSTEM_STATUS_COLORS[status])} />
<div className="relative w-fit min-w-0 max-w-full">
<span className="invisible block whitespace-nowrap" aria-hidden="true">
{longestSystemName}
</span>
<span className="absolute inset-0 truncate">{name}</span>
</div>
</div>
),
[status, name]
)
},
},
{
id: "target",
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
accessorFn: (record) => getMonitorTarget(record),
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
cell: ({ row, getValue }) => {
const monitor = row.original
const { status } = useStore($allSystemsById)[monitor.system] || {}
let color = "bg-green-500"
if (!monitor.enabled || status === SystemStatus.Paused) {
color = "bg-primary/40"
} else if (status === SystemStatus.Down || status === SystemStatus.Pending) {
color = "bg-yellow-500"
}
return (
<div className="ms-1.5 max-w-64 flex gap-2 items-center tabular-nums">
<span className={cn("shrink-0 size-2 rounded-full", color)} />
<div className="relative w-fit min-w-0 max-w-full">
<span className="invisible block overflow-hidden whitespace-nowrap" aria-hidden="true">
{longestTarget}
</span>
<span className="absolute inset-0 truncate">{getValue() as string}</span>
</div>
</div>
)
},
},
{
id: "protocol",
accessorFn: (record) => record.protocol,
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => {
const protocol = getValue() as string
return <Badge className={cn("uppercase", protocolColors[protocol])}>{protocol}</Badge>
},
},
{
id: "interval",
accessorFn: (record) => record.interval,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={RefreshCwIcon} />,
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
},
{
id: "res",
accessorFn: (record) => record.res,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
cell: responseTimeCell,
},
{
id: "res1h",
accessorFn: (record) => record.resAvg1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Avg 1h`} Icon={TimerIcon} />,
cell: responseTimeCell,
},
{
id: "max1h",
accessorFn: (record) => record.resMax1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Max 1h`} Icon={TimerIcon} />,
cell: responseTimeCell,
},
{
id: "min1h",
accessorFn: (record) => record.resMin1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Min 1h`} Icon={TimerIcon} />,
cell: responseTimeCell,
},
{
id: "loss",
accessorFn: (record) => record.loss1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
cell: ({ row }) => {
const { loss1h, res, system } = row.original
const systemRecord = useStore($allSystemsById)[system]
if (loss1h === undefined || (!res && !loss1h)) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
const muted = isMuted(row.original, systemRecord)
let color = "bg-green-500"
if (muted) {
color = "bg-muted-foreground/50"
} else if (loss1h) {
color = loss1h > 20 ? "bg-red-500" : "bg-yellow-500"
}
return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", color)} />
{loss1h === 100 ? loss1h : decimalString(loss1h, loss1h >= 10 ? 1 : 2)}%
</span>
)
},
},
{
id: "updated",
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => {
const timestamp = getValue() as number
if (!timestamp) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(timestamp)}</span>
},
},
{
id: "actions",
enableSorting: false,
enableHiding: false,
header: () => null,
size: 40,
cell: ({ row, table }) => {
const selectedRows = table.getSelectedRowModel().rows
const actionRows =
row.getIsSelected() && selectedRows.length > 1
? selectedRows.map((selectedRow) => selectedRow.original)
: [row.original]
const isBulkAction = actionRows.length > 1
const shouldPause = actionRows.some((monitor) => monitor.enabled)
const bulkCopyContent = actionRows.map((monitor) => formatBulkMonitorLine(monitor)).join("\n")
const allSystems = useStore($allSystemsById)
const otherSystems = useMemo(
() => Object.values(allSystems).filter((s) => !isBulkAction && s.id !== row.original.system),
[allSystems, isBulkAction]
)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-10">
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
{!isBulkAction && (
<DropdownMenuItem
onClick={() => {
onEdit?.(row.original)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
onSetEnabled?.(actionRows, !shouldPause)
}}
>
{shouldPause ? (
<>
<PauseCircleIcon className="me-2.5 size-4" />
<Trans>Pause</Trans>
</>
) : (
<>
<PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
copyToClipboard(bulkCopyContent)
}}
>
<CopyIcon className="me-2.5 size-4" />
<Trans>Bulk copy</Trans>
</DropdownMenuItem>
{!isBulkAction && otherSystems.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<CopyPlusIcon className="me-2.5 size-4" />
<Trans>Copy to system</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] overflow-y-auto">
{otherSystems.map((sys) => (
<DropdownMenuItem
key={sys.id}
onClick={() => {
const { id: _id, system: _system, ...rest } = row.original
pb.collection("network_monitors")
.create({ ...rest, system: sys.id })
.catch(() => {})
}}
>
{sys.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
onDelete?.(actionRows)
}}
>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
}
const responseTimeThresholds = {
http: { warning: 800_000, critical: 3_000_000 },
tcp: { warning: 500_000, critical: 2_000_000 },
icmp: { warning: 100_000, critical: 500_000 },
dns: { warning: 150_000, critical: 800_000 },
}
function responseTimeCell(cell: CellContext<NetworkMonitorRecord, unknown>) {
const monitor = cell.row.original
const systemRecord = useStore($allSystemsById)[monitor.system]
const responseTime = cell.getValue() as number | undefined
if (!responseTime) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
const muted = isMuted(monitor, systemRecord)
let color = "bg-green-500"
if (muted) {
color = "bg-muted-foreground/50"
} else if (responseTime > responseTimeThresholds[monitor.protocol].warning) {
color = "bg-yellow-500"
}
if (!muted && responseTime > responseTimeThresholds[monitor.protocol].critical) {
color = "bg-red-500"
}
return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", color)} />
{formatMicroseconds(responseTime)}
</span>
)
}
function HeaderButton({
column,
name,
Icon,
}: {
column: Column<NetworkMonitorRecord>
name: string
Icon: React.ElementType
}) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="size-4" />}
{name}
</Button>
)
}

View File

@@ -0,0 +1,575 @@
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type Row,
type RowSelectionState,
type SortingState,
type Table as TableType,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { memo, useCallback, useMemo, useRef, useState } from "react"
import { getMonitorColumns } from "@/components/network-monitors-table/network-monitors-columns"
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useToast } from "@/components/ui/use-toast"
import { isReadOnlyUser } from "@/lib/api"
import { pb } from "@/lib/api"
import { $allSystemsById, $direction, $userSettings } from "@/lib/stores"
import {
cn,
isVisuallyLonger,
matchesFilterGroups,
parseFilterGroups,
parseSemVer,
useBrowserStorage,
} from "@/lib/utils"
import type { ChartData, NetworkMonitorRecord } from "@/types"
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
import { ArrowLeftRightIcon, EthernetPortIcon, LoaderCircleIcon, ServerIcon, XIcon } from "lucide-react"
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 { useStore } from "@nanostores/react"
import { atom } from "nanostores"
import { Separator } from "../ui/separator"
import { $router, Link } from "../router"
import { getPagePath } from "@nanostores/router"
export default function NetworkMonitorsTableNew({
systemId,
monitors,
isLoading,
}: {
systemId?: string
monitors: NetworkMonitorRecord[]
isLoading: boolean
}) {
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-np-target-${systemId ? 1 : 0}`,
[{ id: systemId ? "target" : "system", desc: false }],
sessionStorage
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [globalFilter, setGlobalFilter] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([])
const [editingMonitor, setEditingMonitor] = useState<NetworkMonitorRecord>()
const { toast } = useToast()
const canManageMonitors = !isReadOnlyUser()
const longestTarget = useMemo(() => {
let longestTarget = ""
for (const p of monitors) {
if (isVisuallyLonger(getMonitorTarget(p), longestTarget)) {
longestTarget = getMonitorTarget(p)
}
}
return longestTarget
}, [monitors])
const runMonitorBatch = useCallback(
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
let batch = pb.createBatch()
let inBatch = 0
for (const id of ids) {
enqueue(batch, id)
if (++inBatch >= 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
if (inBatch) {
await batch.send()
}
},
[]
)
const handleDeleteRequest = useCallback(
async (monitorsToDelete: NetworkMonitorRecord[]) => {
if (!monitorsToDelete.length) {
return
}
const ids = monitorsToDelete.map((monitor) => monitor.id)
if (ids.length === 1) {
try {
await pb.collection("network_monitors").delete(ids[0])
} catch (err: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (err as Error)?.message || t`Failed to delete monitors.`,
})
}
return
}
setPendingDeleteIds(ids)
setDeleteOpen(true)
},
[toast]
)
const handleBulkDelete = async () => {
setDeleteOpen(false)
if (!pendingDeleteIds.length) {
return
}
try {
await runMonitorBatch(pendingDeleteIds, (batch, id) => batch.collection("network_monitors").delete(id))
setPendingDeleteIds([])
setRowSelection({})
} catch (err: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (err as Error)?.message || t`Failed to delete monitors.`,
})
}
}
const handleSetEnabled = useCallback(
async (monitorsToUpdate: NetworkMonitorRecord[], enabled: boolean) => {
if (!monitorsToUpdate.length) {
return
}
const pendingUpdates = monitorsToUpdate.filter((monitor) => monitor.enabled !== enabled)
if (!pendingUpdates.length) {
return
}
try {
if (pendingUpdates.length === 1) {
await pb.collection("network_monitors").update(pendingUpdates[0].id, { enabled })
return
}
await runMonitorBatch(
pendingUpdates.map((monitor) => monitor.id),
(batch, id) => batch.collection("network_monitors").update(id, { enabled })
)
if (monitorsToUpdate.length > 1) {
setRowSelection({})
}
} catch (err: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (err as Error)?.message || t`Failed to update monitors.`,
})
}
},
[runMonitorBatch, toast]
)
const columns = useMemo(() => {
let columns = getMonitorColumns(longestTarget, {
onEdit: setEditingMonitor,
onDelete: handleDeleteRequest,
onSetEnabled: handleSetEnabled,
})
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
columns = canManageMonitors ? columns : columns.filter((col) => col.id !== "actions")
return columns
}, [canManageMonitors, handleDeleteRequest, handleSetEnabled, systemId, longestTarget])
const table = useReactTable({
data: monitors,
columns,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
defaultColumn: {
sortUndefined: "last",
size: 900,
minSize: 0,
},
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const value = (filterValue as string).trim()
if (!value) return true
const monitor = row.original
const systemName = $allSystemsById.get()[monitor.system]?.name ?? ""
const searchString = `${getMonitorTarget(monitor)}${monitor.protocol}${systemName}`.toLocaleLowerCase()
return matchesFilterGroups(searchString, parseFilterGroups(value))
},
})
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
return (
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Network Monitors</Trans>
</CardTitle>
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
<Trans>Response time monitoring from agents.</Trans>
</div>
</div>
<div className="md:ms-auto flex items-center gap-2">
{monitors.length > 0 && (
<div className="relative">
<Input
placeholder={t`Filter...`}
title={t`Use commas to match any of multiple terms, e.g. "system1, system2"`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-50"
/>
{globalFilter && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t`Clear`}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
onClick={() => setGlobalFilter("")}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</div>
)}
{canManageMonitors ? <AddMonitorDialog systemId={systemId} monitors={monitors} /> : null}
{canManageMonitors ? (
<EditMonitorDialog
systemId={systemId}
monitor={editingMonitor}
open={!!editingMonitor}
setOpen={(open) => {
if (!open) {
setEditingMonitor(undefined)
}
}}
/>
) : null}
<AlertDialog
open={deleteOpen}
onOpenChange={(open) => {
setDeleteOpen(open)
if (!open) {
setPendingDeleteIds([])
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>This will permanently delete all selected records from the database.</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleBulkDelete}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</CardHeader>
<div className="rounded-md">
<NetworkMonitorsTable
table={table}
rows={rows}
colLength={visibleColumns.length}
rowSelection={rowSelection}
isLoading={isLoading}
/>
</div>
</Card>
)
}
const NetworkMonitorsTable = memo(function NetworkMonitorTable({
table,
rows,
colLength,
rowSelection,
isLoading,
}: {
table: TableType<NetworkMonitorRecord>
rows: Row<NetworkMonitorRecord>[]
colLength: number
rowSelection: RowSelectionState
isLoading: boolean
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [activeMonitorId, setActiveMonitorId] = useState<string | null>(null)
const activeMonitor = activeMonitorId
? table.options.data.find((monitor) => monitor.id === activeMonitorId)
: undefined
const openSheet = useCallback((monitor: NetworkMonitorRecord) => {
setActiveMonitorId(monitor.id)
setSheetOpen(true)
}, [])
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 54,
getScrollElement: () => scrollRef.current,
overscan: 5,
})
const virtualRows = virtualizer.getVirtualItems()
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
return (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<NetworkMonitorTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<NetworkMonitorTableRow
key={row.id}
row={row}
virtualRow={virtualRow}
isSelected={row.getIsSelected()}
rowSelection={rowSelection}
openSheet={openSheet}
/>
)
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
{isLoading ? (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
) : (
<Trans>No results.</Trans>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
<NetworkMonitorSheet
open={sheetOpen}
onOpenChange={(nextOpen) => {
setSheetOpen(nextOpen)
}}
monitor={activeMonitor}
/>
</div>
)
})
function NetworkMonitorTableHead({ table }: { table: TableType<NetworkMonitorRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</tr>
))}
</TableHeader>
)
}
const NetworkMonitorTableRow = memo(function NetworkMonitorTableRow({
row,
virtualRow,
isSelected,
rowSelection: _rowSelection,
openSheet,
}: {
row: Row<NetworkMonitorRecord>
virtualRow: VirtualItem
isSelected: boolean
// Menus depend on the entire selection, including changes to other rows.
rowSelection: RowSelectionState
openSheet: (monitor: NetworkMonitorRecord) => void
}) {
return (
<TableRow
data-state={isSelected && "selected"}
className="cursor-pointer transition-opacity"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0"
style={{
width: `${cell.column.getSize()}px`,
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})
function NetworkMonitorSheet({
open,
onOpenChange,
monitor,
}: {
open: boolean
onOpenChange: (open: boolean) => void
monitor?: NetworkMonitorRecord
}) {
if (!monitor) {
return null
}
return <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} />
}
function NetworkMonitorSheetContent({
open,
onOpenChange,
monitor,
}: {
open: boolean
onOpenChange: (open: boolean) => void
monitor: NetworkMonitorRecord
}) {
// Keep monitor exploration independent of the system charts' time range.
const [chartTimeStore] = useState(() => {
const defaultTime = $userSettings.get().chartTime
return atom(defaultTime === "1m" ? "1h" : defaultTime)
})
const chartTime = useStore(chartTimeStore)
const direction = useStore($direction)
const system = useStore($allSystemsById)[monitor.system]
const monitorStats = useNetworkMonitorStats({
systemId: monitor.system,
monitorId: monitor.id,
chartTime,
enabled: open,
})
const chartData = useMemo<ChartData>(
() => ({
agentVersion: parseSemVer(system?.info?.v),
orientation: direction === "rtl" ? "right" : "left",
chartTime,
}),
[system?.info?.v, direction, chartTime]
)
const hasMonitorStats = monitorStats.some((record) => record.stats?.[monitor.id] != null)
const monitorLabel = getMonitorTarget(monitor)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-220 overflow-auto p-4 sm:p-6">
<SheetHeader className="mb-0 border-b p-0 pb-4">
<SheetTitle>{monitorLabel}</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<ServerIcon className="size-3.5 text-muted-foreground" />
<Link className="hover:underline" href={getPagePath($router, "system", { id: system?.id ?? "" })}>
{system?.name ?? ""}
</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground" />
{monitor.protocol.toUpperCase()}
{monitor.protocol === "tcp" && monitor.port > 0 && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<EthernetPortIcon className="size-3.5 text-muted-foreground" />
<span>{monitor.port}</span>
</>
)}
</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>
</SheetContent>
</Sheet>
)
}

View File

@@ -4,6 +4,7 @@ const routes = {
home: "/",
containers: "/containers",
smart: "/smart",
monitors: "/monitors",
system: `/system/:id`,
settings: `/settings/:name?`,
forgot_password: `/forgot-password`,

View File

@@ -0,0 +1,33 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect } from "react"
import NetworkMonitorsTableNew from "@/components/network-monitors-table/network-monitors-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
import { useNetworkMonitors } from "@/lib/use-network-monitors"
import { $allSystemsById } from "@/lib/stores"
import { supportsNetworkMonitors } from "@/lib/utils"
import { useStore } from "@nanostores/react"
export default memo(() => {
const { t } = useLingui()
const { monitors, isLoading } = useNetworkMonitors({})
const systems = useStore($allSystemsById)
const visibleMonitors = monitors.filter((monitor) => {
const system = systems[monitor.system]
return !system || supportsNetworkMonitors(system)
})
useEffect(() => {
document.title = `${t`Network Monitors`} / Beszel`
}, [t])
return (
<>
<div className="grid gap-4">
<ActiveAlerts />
<NetworkMonitorsTableNew monitors={visibleMonitors} isLoading={isLoading} />
</div>
<FooterRepoLink />
</>
)
})

View File

@@ -78,7 +78,7 @@ export default function AlertsHistoryDataTable() {
let unsubscribe: (() => void) | undefined
const pbOptions = {
expand: "system",
fields: "id,name,value,state,created,resolved,expand.system.name",
fields: "id,name,monitor_name,value,state,created,resolved,expand.system.name",
}
// Initial load
pb.collection<AlertsHistoryRecord>("alerts_history")
@@ -199,7 +199,7 @@ export default function AlertsHistoryDataTable() {
if (!selectedRows.length) return
const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
system: (record) => record.expand?.system?.name || record.system,
name: (record) => alertInfo[record.name]?.name() || record.name,
name: (record) => [alertInfo[record.name]?.name() || record.name, record.monitor_name].filter(Boolean).join(": "),
value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
state: (record) => (record.resolved ? t`Resolved` : t`Active`),
created: (record) => formatShortDate(record.created),

View File

@@ -1,6 +1,6 @@
import { memo, useState } from "react"
import { Trans } from "@lingui/react/macro"
import { compareSemVer, parseSemVer } from "@/lib/utils"
import { compareSemVer, parseSemVer, supportsNetworkMonitors } from "@/lib/utils"
import type { GPUData } from "@/types"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import InfoBar from "./system/info-bar"
@@ -12,9 +12,15 @@ import { ZfsCharts } from "./system/charts/storage-pool-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyZfsTable } from "./system/lazy-tables"
import {
LazyContainersTable,
LazyNetworkMonitorsTable,
LazySmartTable,
LazySystemdTable,
LazyZfsTable,
} from "./system/lazy-tables"
import { LoadAverageChart } from "./system/charts/load-average-chart"
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
import { ContainerIcon, CpuIcon, HardDriveIcon, NetworkIcon, TerminalSquareIcon } from "lucide-react"
import { GpuIcon } from "../ui/icons"
import SystemdTable from "../systemd-table/systemd-table"
import ContainersTable from "../containers-table/containers-table"
@@ -65,9 +71,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasSystemd = system.info.sv
const hasGpu = hasGpuData || hasGpuPowerData
const hasZfs = Object.keys(systemStats.at(-1)?.stats?.z ?? {}).length > 0
const hasNetworkMonitors = supportsNetworkMonitors(system)
// keep tabsRef in sync for keyboard navigation
const tabs = ["core", "disk"]
const tabs = ["core", "network", "disk"]
if (hasGpu) tabs.push("gpu")
if (hasContainers) tabs.push("containers")
if (hasSystemd) tabs.push("services")
@@ -153,6 +160,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
{hasSystemd && <LazySystemdTable systemId={system.id} />}
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
</>
)
}
@@ -165,6 +174,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<CpuIcon className="size-3.5" />
<Trans context="Core system metrics">Core</Trans>
</TabsTrigger>
<TabsTrigger value="network" className="w-full flex items-center gap-1.5">
<NetworkIcon className="size-3.5" />
<Trans>Network</Trans>
</TabsTrigger>
<TabsTrigger value="disk" className="w-full flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />
<Trans>Disk</Trans>
@@ -192,9 +205,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
<div className="grid xl:grid-cols-2 gap-4">
<CpuChart {...coreProps} />
<MemoryChart {...coreProps} />
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
<BandwidthChart {...coreProps} systemStats={systemStats} />
<MemoryChart {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
<FanChart {...coreProps} />
<BatteryChart system={system} {...coreProps} />
@@ -203,6 +216,17 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</div>
</TabsContent>
<TabsContent value="network" forceMount className={activeTab === "network" ? "contents" : "hidden"}>
{mountedTabs.has("network") && (
<>
<div className="grid xl:grid-cols-2 gap-4">
<BandwidthChart {...coreProps} systemStats={systemStats} />
</div>
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
</>
)}
</TabsContent>
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
{mountedTabs.has("disk") && (
<>

View File

@@ -44,6 +44,7 @@ export function FilterBar({ store = $containerFilter }: { store?: typeof $contai
<>
<Input
placeholder={t`Filter...`}
title={t`Use commas to match any of multiple terms, e.g. "system1, system2"`}
className="ps-4 pe-8 w-full sm:w-44"
onChange={handleChange}
value={inputValue}

View File

@@ -1,7 +1,13 @@
import { timeTicks } from "d3-time"
import { getPbTimestamp, pb } from "@/lib/api"
import { chartTimeData } from "@/lib/utils"
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
import type {
ChartData,
ChartDataContainer,
ChartTimes,
ContainerStatsRecord,
NetworkMonitorStatsRecord,
SystemStatsRecord,
} from "@/types"
type ChartTimeData = {
time: number
@@ -17,31 +23,10 @@ export const cache = new Map<
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
>()
// create ticks and domain for charts
export function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get("td") as ChartTimeData | undefined
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
}
}
// const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now())
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
}
cache.set("td", { time: now.getTime(), data, chartTime })
return data
}
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
export function appendData<T extends { created: string | number | null }>(
prev: T[],
prev: T[] = [],
newRecords: T[],
expectedInterval: number,
maxLen?: number
@@ -66,17 +51,18 @@ export function appendData<T extends { created: string | number | null }>(
return result
}
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord | NetworkMonitorStatsRecord>(
collection: string,
systemId: string,
chartTime: ChartTimes
chartTime: ChartTimes,
cachedStats?: { created: string | number | null }[],
createdIsNumber?: boolean
): Promise<T[]> {
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
const lastCached = cachedStats?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
id: systemId,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined, createdIsNumber),
type: chartTimeData[chartTime].type,
}),
fields: "created,stats",
@@ -84,11 +70,11 @@ export async function getStats<T extends SystemStatsRecord | ContainerStatsRecor
})
}
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] {
const result = [] as ChartData["containerData"]
export function makeContainerData(containers: ContainerStatsRecord[]): ChartDataContainer[] {
const result = [] as ChartDataContainer[]
for (const { created, stats } of containers) {
if (!created) {
result.push({ created: null } as ChartData["containerData"][0])
result.push({ created: null } as ChartDataContainer)
continue
}
result.push(makeContainerPoint(new Date(created).getTime(), stats))
@@ -97,11 +83,8 @@ export function makeContainerData(containers: ContainerStatsRecord[]): ChartData
}
/** Transform a single realtime container stats message into a ChartDataContainer point. */
export function makeContainerPoint(
created: number,
stats: ContainerStatsRecord["stats"]
): ChartData["containerData"][0] {
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
export function makeContainerPoint(created: number, stats: ContainerStatsRecord["stats"]): ChartDataContainer {
const point: ChartDataContainer = { created } as ChartDataContainer
for (const container of stats) {
;(point as Record<string, unknown>)[container.n] = container
}

View File

@@ -0,0 +1,212 @@
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import LineChartDefault from "@/components/charts/line-chart"
import type { DataPoint } from "@/components/charts/line-chart"
import { decimalString, formatMicroseconds, matchesFilterGroups, parseFilterGroups, toFixedFloat } from "@/lib/utils"
import { $monitorFilter } from "@/lib/stores"
import { useLingui } from "@lingui/react/macro"
import { ChartCard, FilterBar } from "../chart-card"
import type { ChartData, MonitorStats, NetworkMonitorRecord, NetworkMonitorStatsRecord } from "@/types"
import { useMemo } from "react"
import { useStore } from "@nanostores/react"
type MonitorChartProps = {
monitorStats: NetworkMonitorStatsRecord[]
grid?: boolean
monitors: NetworkMonitorRecord[]
chartData: ChartData
empty: boolean
showFilter?: boolean
/** Prepended to the chart title, e.g. a target/system name (rendered as "{titlePrefix} — Response"). */
titlePrefix?: string
}
type MonitorChartBaseProps = MonitorChartProps & {
metric: keyof MonitorStats
title: string
description: string
tickFormatter: (value: number) => string
contentFormatter: ({ value }: { value: number | string }) => string | number
domain?: [number | "auto", number | "auto"]
}
function MonitorChart({
monitorStats,
grid,
monitors,
chartData,
empty,
metric,
title,
description,
tickFormatter,
contentFormatter,
domain,
showFilter = monitors.length > 1,
}: MonitorChartBaseProps) {
const storedFilter = useStore($monitorFilter)
const filter = showFilter ? storedFilter : ""
const { dataPoints, visibleKeys } = useMemo(() => {
const sortedMonitors = [...monitors].sort((a, b) => b.resAvg1h - a.resAvg1h)
const count = sortedMonitors.length
const points: DataPoint<NetworkMonitorStatsRecord>[] = []
const visibleIDs: string[] = []
const filterGroups = parseFilterGroups(filter)
const dot = chartData.chartTime === "1m"
for (let i = 0; i < count; i++) {
const p = sortedMonitors[i]
const label = getMonitorTarget(p)
const labelLower = label.toLowerCase()
const filtered = filterGroups.length > 0 && !matchesFilterGroups(labelLower, filterGroups)
if (filtered) {
continue
}
visibleIDs.push(p.id)
points.push({
order: i,
label,
dataKey: (record: NetworkMonitorStatsRecord) => record.stats?.[p.id]?.[metric] ?? null,
dot,
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
})
}
return { dataPoints: points, visibleKeys: visibleIDs }
}, [monitors, filter, metric, chartData.chartTime])
const filteredMonitorStats = useMemo(() => {
if (!visibleKeys.length) return monitorStats
return monitorStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
}, [monitorStats, visibleKeys])
const legend = dataPoints.length < 10 && showFilter
return (
<ChartCard
legend={legend || !showFilter}
cornerEl={showFilter ? <FilterBar store={$monitorFilter} /> : undefined}
empty={empty}
title={title}
description={description}
grid={grid}
>
<LineChartDefault
truncate
chartData={chartData}
customData={filteredMonitorStats}
dataPoints={dataPoints}
domain={domain ?? ["auto", "auto"]}
connectNulls
tickFormatter={tickFormatter}
contentFormatter={contentFormatter}
legend={legend}
filter={filter}
/>
</ChartCard>
)
}
interface AvgMinMaxResponseChartProps {
monitorStats: NetworkMonitorStatsRecord[]
monitor: NetworkMonitorRecord | null
chartData: ChartData
empty: boolean
}
export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty }: AvgMinMaxResponseChartProps) {
const { t } = useLingui()
const { chartTime } = chartData
const hasLongInterval = (monitor?.interval ?? 61) > 60
// only one monitor is relevant for this chart
const dataPoints: DataPoint<NetworkMonitorStatsRecord>[] = useMemo(() => {
const dataFn = (metric: keyof MonitorStats) => (record: NetworkMonitorStatsRecord) =>
record.stats?.[monitor?.id ?? ""]?.[metric] ?? "-"
const avgPoint = {
label: "Avg",
dataKey: dataFn("res_avg"),
color: 1,
order: 0,
}
if (chartTime === "1m" || (hasLongInterval && chartTime === "1h")) {
// avg, min, max are all the same for 1m interval, so just show avg
return [avgPoint]
}
return [
{
label: "Max",
dataKey: dataFn("res_max"),
color: 3,
order: 0,
},
avgPoint,
{
label: "Min",
dataKey: dataFn("res_min"),
color: 2,
order: 2,
},
]
}, [chartTime, hasLongInterval, monitor?.id])
const data = useMemo(() => {
if (!monitor) return []
return monitorStats.filter((record) => record.stats && monitor.id in record.stats)
}, [monitor, monitorStats])
const legend = dataPoints.length > 1
return (
<ChartCard
legend={true}
empty={empty}
title={t`Response`}
description={t`Average, minimum, and maximum response time`}
grid={false}
>
<LineChartDefault
truncate
chartData={chartData}
customData={data}
dataPoints={dataPoints}
domain={["auto", "auto"]}
connectNulls
legend={legend}
tickFormatter={(value) => formatMicroseconds(value, false)}
contentFormatter={({ value }) => {
if (typeof value !== "number") {
return value
}
return formatMicroseconds(value)
}}
/>
</ChartCard>
)
}
export function LossChart({ monitorStats, grid, monitors, chartData, empty, titlePrefix }: MonitorChartProps) {
const { t } = useLingui()
const lossTitle = t`Loss`
const title = titlePrefix ? `${titlePrefix} — ${lossTitle}` : lossTitle
return (
<MonitorChart
monitorStats={monitorStats}
grid={grid}
monitors={monitors}
chartData={chartData}
empty={empty}
metric="loss"
title={title}
description={t`Packet loss (%)`}
domain={[0, 100]}
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
contentFormatter={({ value }) => {
if (typeof value !== "number") {
return value
}
return `${decimalString(value, 2)}%`
}}
/>
)
}

View File

@@ -1,6 +1,7 @@
import { lazy } from "react"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
import { useNetworkMonitors } from "@/lib/use-network-monitors"
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
@@ -45,3 +46,19 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
</div>
)
}
const NetworkMonitorsTable = lazy(() => import("../../network-monitors-table/network-monitors-table"))
export function LazyNetworkMonitorsTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <SystemNetworkMonitorsTable systemId={systemId} />}
</div>
)
}
function SystemNetworkMonitorsTable({ systemId }: { systemId: string }) {
const { monitors, isLoading } = useNetworkMonitors({ systemId })
return <NetworkMonitorsTable systemId={systemId} monitors={monitors} isLoading={isLoading} />
}

View File

@@ -43,7 +43,6 @@ import {
toFixedFloat,
formatTemperature,
cn,
getVisualStringWidth,
secondsToString,
hourWithSeconds,
formatShortDate,
@@ -117,9 +116,9 @@ function formatDataUnits(units: number): string {
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
export const createColumns = (
longestName: number,
longestModel: number,
longestDevice: number
longestName: string,
longestModel: string,
longestDevice: string
): ColumnDef<SmartDeviceRecord>[] => [
{
id: "system",
@@ -134,8 +133,11 @@ export const createColumns = (
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return (
<div className="ms-1.5 max-w-40 block truncate" style={{ width: `${longestName / 1.05}ch` }}>
{allSystems[getValue() as string]?.name ?? ""}
<div className="ms-1.5 relative w-fit max-w-44">
<span className="invisible block whitespace-nowrap" aria-hidden="true">
{longestName}
</span>
<span className="absolute inset-0 truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
</div>
)
},
@@ -145,12 +147,11 @@ export const createColumns = (
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
cell: ({ getValue }) => (
<div
className="font-medium max-w-40 truncate ms-1"
title={getValue() as string}
style={{ width: `${longestDevice / 1.05}ch` }}
>
{getValue() as string}
<div className="font-medium ms-1 relative w-fit max-w-44" title={getValue() as string}>
<span className="invisible block whitespace-nowrap" aria-hidden="true">
{longestDevice}
</span>
<span className="absolute inset-0 truncate">{getValue() as string}</span>
</div>
),
},
@@ -161,12 +162,11 @@ export const createColumns = (
<HeaderButton column={column} name={t({ message: "Model", comment: "Device model" })} Icon={Box} />
),
cell: ({ getValue }) => (
<div
className="max-w-48 truncate ms-1"
title={getValue() as string}
style={{ width: `${longestModel / 1.05}ch` }}
>
{getValue() as string}
<div className="ms-1 relative w-fit max-w-44" title={getValue() as string}>
<span className="invisible block whitespace-nowrap" aria-hidden="true">
{longestModel}
</span>
<span className="absolute inset-0 truncate">{getValue() as string}</span>
</div>
),
},
@@ -320,7 +320,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
// Calculate the right width for the columns based on the longest strings among the displayed devices
const { longestName, longestModel, longestDevice } = useMemo(() => {
const result = { longestName: 0, longestModel: 0, longestDevice: 0 }
const result = { longestName: "", longestModel: "", longestDevice: "" }
if (!smartDevices || Object.keys(allSystems).length === 0) {
return result
}
@@ -329,10 +329,16 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
if (!systemId && !seenSystems.has(device.system)) {
seenSystems.add(device.system)
const name = allSystems[device.system]?.name ?? ""
result.longestName = Math.max(result.longestName, getVisualStringWidth(name))
if (name.length > result.longestName.length) {
result.longestName = name
}
}
if ((device.model ?? "").length > result.longestModel.length) {
result.longestModel = device.model ?? ""
}
if ((device.name ?? "").length > result.longestDevice.length) {
result.longestDevice = device.name ?? ""
}
result.longestModel = Math.max(result.longestModel, getVisualStringWidth(device.model ?? ""))
result.longestDevice = Math.max(result.longestDevice, getVisualStringWidth(device.name ?? ""))
}
return result
}, [smartDevices, systemId, allSystems])

View File

@@ -26,7 +26,7 @@ import type {
SystemStatsRecord,
} from "@/types"
import { $router, navigate } from "../../router"
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
import { appendData, cache, getStats, makeContainerData, makeContainerPoint } from "./chart-data"
export type SystemData = ReturnType<typeof useSystemData>
@@ -185,16 +185,11 @@ export function useSystemData(id: string) {
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
(containerData.at(-1)?.created as number) ?? 0
)
return {
systemStats,
containerData,
chartTime,
orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated),
agentVersion,
}
}, [systemStats, containerData, direction])
@@ -234,8 +229,8 @@ export function useSystemData(id: string) {
}
Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData),
]).then(([systemStats, containerStats]) => {
// Ignore responses for a previous system or chart time
if (requestId !== statsRequestId.current) {
@@ -327,7 +322,7 @@ export function useSystemData(id: string) {
// derived values
const isLongerChart = !["1m", "1h"].includes(chartTime)
const showMax = maxValues && isLongerChart
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const dataEmpty = !chartLoading && chartData.systemStats?.length === 0
const lastGpus = systemStats.at(-1)?.stats?.g
const isPodman = details?.podman ?? system.info?.p ?? false

View File

@@ -26,7 +26,7 @@ import { memo, useMemo, useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
import { isReadOnlyUser, pb } from "@/lib/api"
import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
import { $longestSystemName, $userSettings } from "@/lib/stores"
import {
cn,
copyToClipboard,
@@ -135,7 +135,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
Icon: ServerIcon,
cell: (info) => {
const { name, id } = info.row.original
const longestName = useStore($longestSystemNameLen)
const longestName = useStore($longestSystemName)
const linkUrl = getPagePath($router, "system", { id })
return (
@@ -145,8 +145,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
<Link
href={linkUrl}
tabIndex={-1}
className="truncate z-10 relative"
style={{ width: `${longestName / 1.05}ch` }}
className="relative w-fit max-w-48 z-10"
onMouseEnter={(e) => {
// set title on hover if text is truncated to show full name
const a = e.currentTarget
@@ -157,7 +156,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
}
}}
>
{name}
<span className="invisible block" aria-hidden="true">
{longestName}
</span>
<span className="absolute inset-0 truncate">{name}</span>
</Link>
</span>
<Link href={linkUrl} className="inset-0 absolute size-full" aria-label={name}></Link>

View File

@@ -1,11 +1,11 @@
import type { JSX } from "react"
import { useLingui } from "@lingui/react/macro"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { chartTimeData, cn } from "@/lib/utils"
import type { ChartData } from "@/types"
import type { ChartTimes } from "@/types"
import { Separator } from "./separator"
import { AxisDomain } from "recharts/types/util/types"
import type { AxisDomain } from "recharts/types/util/types"
import { timeTicks } from "d3-time"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
@@ -101,7 +101,7 @@ const ChartTooltipContent = React.forwardRef<
labelKey?: string
unit?: string
filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string
contentFormatter?: (item: unknown, key: string) => React.ReactNode | string
truncate?: boolean
showTotal?: boolean
totalLabel?: React.ReactNode
@@ -175,7 +175,13 @@ const ChartTooltipContent = React.forwardRef<
}
const totalKey = "__total__"
const totalItem: any = {
const totalItem: {
value: number
name: string
dataKey: string
color: string | undefined
payload?: unknown
} = {
value: totalValue,
name: totalName,
dataKey: totalKey,
@@ -222,6 +228,11 @@ const ChartTooltipContent = React.forwardRef<
return null
}
payload = payload.filter((item) => item.value != null)
if (!payload.length) {
return null
}
// const nestLabel = payload.length === 1 && indicator !== 'dot'
const nestLabel = false
@@ -400,26 +411,57 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
}
let cachedAxis: JSX.Element
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
return cachedAxis
interface XAxisData {
el: React.ReactElement
domain: [number, number]
}
const xAxisCache = new Map<ChartTimes, XAxisData>()
function createXAxisData(chartTime: ChartTimes): XAxisData {
// console.log("Creating XAxis for", chartTime, new Date())
const axisEndTime = Date.now() + 500
const axisEndDate = new Date(axisEndTime)
const startTime = chartTimeData[chartTime].getOffset(axisEndDate)
const ticks = timeTicks(startTime, axisEndDate, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const domain: [number, number] = [startTime.getTime(), axisEndTime]
return {
domain,
el: (
<RechartsPrimitive.XAxis
dataKey="created"
domain={domain}
ticks={ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={12}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
),
}
cachedAxis = (
<RechartsPrimitive.XAxis
dataKey="created"
domain={domain}
ticks={ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={12}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
)
return cachedAxis
}
function xAxis(chartTime: ChartTimes, lastCreated: number) {
if (!lastCreated) {
return null
}
const cachedAxis = xAxisCache.get(chartTime)
const expectedInterval = chartTimeData[chartTime].expectedInterval
const conservativeEndTime = Date.now() - expectedInterval / 2
const axisEndTime = Math.max(lastCreated, conservativeEndTime)
if (cachedAxis && axisEndTime < cachedAxis.domain[1]) {
return cachedAxis.el
}
const axisData = createXAxisData(chartTime)
xAxisCache.set(chartTime, axisData)
return axisData.el
}
export {

View File

@@ -90,8 +90,10 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
indicatorClassName?: string
}
>(({ className, children, checked, indicatorClassName, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
@@ -101,7 +103,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className={cn("absolute left-2 flex h-3.5 w-3.5 items-center justify-center", indicatorClassName)}>
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>

View File

@@ -41,7 +41,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr
ref={ref}
className={cn(
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted!",
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted/40!",
className
)}
{...props}

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro"
import { ContainerIcon, CpuIcon, HardDriveIcon, MemoryStickIcon, ServerCrashIcon, ServerIcon } from "lucide-react"
import { ContainerIcon, CpuIcon, HardDriveIcon, MemoryStickIcon, NetworkIcon, ServerCrashIcon, ServerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase"
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
import { $alerts } from "@/lib/stores"
@@ -9,210 +9,223 @@ import { ThermometerIcon, BatteryMediumIcon, HourglassIcon } from "@/components/
/** Alert info for each alert type */
export const alertInfo: Record<string, AlertInfo> = {
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => `${t`System`} ${t`Down`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
CPUIOWait: {
name: () => t`CPU I/O Wait`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU I/O wait exceeds a threshold`,
},
CPUSteal: {
name: () => t`CPU Steal Time`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU steal time exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 250,
},
GPU: {
name: () => t`GPU Usage`,
unit: "%",
icon: GpuIcon,
desc: () => t`Triggers when GPU usage exceeds a threshold`,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
Battery: {
name: () => t`Battery`,
unit: "%",
icon: BatteryMediumIcon,
desc: () => t`Triggers when battery charge drops below a threshold`,
start: 20,
invert: true,
},
ContainerHealth: {
name: () => t`Container Health`,
unit: "",
icon: ContainerIcon,
desc: () => t`Triggers when a container's health check reports unhealthy`,
note: () =>
t`Notifications may include recent container log excerpts.`,
triggeredDesc: () => t`One or more containers are unhealthy`,
singleDesc: () => `${t`Container`} ${t`Unhealthy`}`,
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => `${t`System`} ${t`Down`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
CPUIOWait: {
name: () => t`CPU I/O Wait`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU I/O wait exceeds a threshold`,
},
CPUSteal: {
name: () => t`CPU Steal Time`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU steal time exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 250,
},
NetworkMonitorLoss: {
name: () => t`Network Monitor Loss`,
unit: "%",
icon: NetworkIcon,
desc: () => t`Triggers when one hour loss exceeds a threshold`,
// note: () => t`Uses available history after three probes.`,
noDuration: true,
min: 0,
max: 99.9,
step: 0.1,
start: 5,
},
GPU: {
name: () => t`GPU Usage`,
unit: "%",
icon: GpuIcon,
desc: () => t`Triggers when GPU usage exceeds a threshold`,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
Battery: {
name: () => t`Battery`,
unit: "%",
icon: BatteryMediumIcon,
desc: () => t`Triggers when battery charge drops below a threshold`,
start: 20,
invert: true,
},
ContainerHealth: {
name: () => t`Container Health`,
unit: "",
icon: ContainerIcon,
desc: () => t`Triggers when a container's health check reports unhealthy`,
note: () =>
t`Notifications may include recent container log excerpts.`,
triggeredDesc: () => t`One or more containers are unhealthy`,
singleDesc: () => `${t`Container`} ${t`Unhealthy`}`,
},
SystemdFailed: {
name: () => t`Failed Services`,
unit: "",
icon: ServerCrashIcon,
desc: () => t`Triggers when any systemd service enters the failed state`,
triggeredDesc: () => t`One or more services are in a failed state`,
/** Fires on first observation - the agent only polls systemd every 10 minutes */
noDuration: true,
},
},
SystemdFailed: {
name: () => t`Failed Services`,
unit: "",
icon: ServerCrashIcon,
desc: () => t`Triggers when any systemd service enters the failed state`,
triggeredDesc: () => t`One or more services are in a failed state`,
/** Fires on first observation - the agent only polls systemd every 10 minutes */
noDuration: true,
noThreshold: true,
},
} as const
/** Helper to manage user alerts */
export const alertManager = (() => {
const collection = pb.collection<AlertRecord>("alerts")
let unsub: () => void
const collection = pb.collection<AlertRecord>("alerts")
let unsub: () => void
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
async function subscribe() {
unsub = await collection.subscribe("*", batchUpdate, { fields })
}
async function subscribe() {
unsub = await collection.subscribe("*", batchUpdate, { fields })
}
function unsubscribe() {
unsub?.()
}
function unsubscribe() {
unsub?.()
}
async function refresh() {
const records = await fetchAlerts()
add(records)
}
async function refresh() {
const records = await fetchAlerts()
add(records)
}
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Subscribe to alerts */
subscribe,
/** Unsubscribe from alerts */
unsubscribe,
/** Refresh alerts with latest data from hub */
refresh,
}
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Subscribe to alerts */
subscribe,
/** Unsubscribe from alerts */
unsubscribe,
/** Refresh alerts with latest data from hub */
refresh,
}
})()

View File

@@ -106,8 +106,11 @@ export async function updateUserSettings() {
}
}
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
export function getPbTimestamp(timeString: ChartTimes, d?: Date, createdIsNumber?: boolean) {
d ||= chartTimeData[timeString].getOffset(new Date())
if (createdIsNumber) {
return d.getTime()
}
const year = d.getUTCFullYear()
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
const day = String(d.getUTCDate()).padStart(2, "0")

View File

@@ -0,0 +1,17 @@
import type { MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
return {
res_avg: record.success_count > 0 ? record.res_sum / record.success_count : 0,
res_min: record.res_min,
res_max: record.res_max,
loss: record.total_count > 0 ? ((record.total_count - record.success_count) / record.total_count) * 100 : 0,
}
}
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
return `${host}:${monitor.port}`
}

View File

@@ -64,6 +64,9 @@ export const $containerFilter = atom("")
/** Temperature chart filter */
export const $temperatureFilter = atom("")
/** Filter for network monitor charts (compare page and per-system monitor charts) */
export const $monitorFilter = atom("")
/** Fan-speed chart filter */
export const $fanFilter = atom("")
@@ -73,7 +76,5 @@ export const $copyContent = atom("")
/** Direction for localization */
export const $direction = atom<"ltr" | "rtl">("ltr")
/** Longest system name length. Used to set table column width. I know this
* is stupid but the table is virtualized and I know this will work.
*/
export const $longestSystemNameLen = atom(8)
/** Longest system name string. Used to reserve width in virtualized tables. */
export const $longestSystemName = atom("")

View File

@@ -5,20 +5,17 @@ import {
$allSystemsById,
$allSystemsByName,
$downSystems,
$longestSystemNameLen,
$longestSystemName,
$pausedSystems,
$upSystems,
} from "@/lib/stores"
import { getVisualStringWidth, updateFavicon } from "@/lib/utils"
import { isVisuallyLonger, updateFavicon } from "@/lib/utils"
import type { SystemRecord } from "@/types"
import { SystemStatus } from "./enums"
const COLLECTION = pb.collection<SystemRecord>("systems")
const FIELDS_DEFAULT = "id,name,host,port,info,status"
/** Maximum system name length for display purposes */
const MAX_SYSTEM_NAME_LENGTH = 22
let initialized = false
// biome-ignore lint/suspicious/noConfusingVoidType: typescript rocks
let unsub: (() => void) | undefined | void
@@ -44,7 +41,7 @@ export function init() {
}
if (!newSystem) {
onSystemsChanged(newSystems, undefined)
onSystemsChanged(newSystems, newSystem, oldSystem)
return
}
@@ -68,20 +65,28 @@ export function init() {
}
// run things that need to be done when systems change
onSystemsChanged(newSystems, newSystem)
onSystemsChanged(newSystems, newSystem, oldSystem)
})
}
/** Update the longest system name length and favicon based on system status */
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
/** Update the longest system name string and favicon based on system status */
function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: SystemRecord, oldSystem?: SystemRecord) {
const downSystemsStore = $downSystems.get()
const downSystems = Object.values(downSystemsStore)
// Update longest system name length
const longestName = $longestSystemNameLen.get()
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, getVisualStringWidth(changedSystem?.name || ""))
if (nameLen > longestName) {
$longestSystemNameLen.set(nameLen)
// if the old system's old name was the longest, we need to find the new longest name
// otherwise, if the changed system's new name is longer than the current longest, update it
const longestName = $longestSystemName.get()
if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) {
let newLongest = ""
for (const id in systems) {
if (isVisuallyLonger(systems[id].name, newLongest)) {
newLongest = systems[id].name
}
}
$longestSystemName.set(newLongest)
} else if (newSystem && newSystem.name !== longestName && isVisuallyLonger(newSystem.name, longestName)) {
$longestSystemName.set(newSystem.name)
}
updateFavicon(downSystems.length)

View File

@@ -0,0 +1,351 @@
import { chartTimeData } from "@/lib/utils"
import { getMonitorStats } from "@/lib/network-monitor-utils"
import type {
ChartTimes,
MonitorStats,
NetworkMonitorRecord,
NetworkMonitorStatsRecord,
RawMonitorStatsRecord,
} from "@/types"
import { useEffect, useRef, useState } from "react"
import { appendData } from "@/components/routes/system/chart-data"
import { pb, getPbTimestamp } from "@/lib/api"
import { toast } from "@/components/ui/use-toast"
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 appendCacheValue(
monitorId: 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
}
}
/** 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 }))
}
/** Fetch stats for one monitor and time range, returning merged chart records. */
async function fetchMonitorStats(
monitorId: string,
chartTime: ChartTimes,
cached?: NetworkMonitorStatsRecord[]
): Promise<NetworkMonitorStatsRecord[]> {
const lastCached = cached?.at(-1)?.created as number | undefined
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),
type: chartTimeData[chartTime].type,
}),
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created",
sort: "created",
})
return mergeMonitorStats(rawRecords)
}
const NETWORK_MONITOR_FIELDS =
"id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
interface UseNetworkMonitorsProps {
systemId?: string
}
export function useNetworkMonitors(props: UseNetworkMonitorsProps) {
const { systemId } = props
const [monitors, setMonitors] = useState<NetworkMonitorRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const pendingMonitorEvents = useRef(new Map<string, RecordSubscription<NetworkMonitorRecord>>())
const monitorBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
// initial load
useEffect(() => {
let cancelled = false
setIsLoading(true)
setMonitors([])
fetchMonitors(systemId).then((monitors) => {
if (cancelled) return
setMonitors(monitors)
setIsLoading(false)
})
return () => {
cancelled = true
}
}, [systemId])
// subscribe to updates
useEffect(() => {
let unsubscribe: (() => void) | undefined
function flushPendingMonitorEvents() {
monitorBatchTimeout.current = null
if (!pendingMonitorEvents.current.size) {
return
}
const events = pendingMonitorEvents.current
pendingMonitorEvents.current = new Map()
setMonitors((currentMonitors) => {
return applyMonitorEvents(currentMonitors ?? [], events.values(), systemId)
})
}
const pbOptions: RecordListOptions = { fields: NETWORK_MONITOR_FIELDS }
if (systemId) {
pbOptions.filter = pb.filter("system = {:system}", { system: systemId })
}
;(async () => {
try {
unsubscribe = await pb.collection<NetworkMonitorRecord>("network_monitors").subscribe(
"*",
(event) => {
pendingMonitorEvents.current.set(event.record.id, event)
if (!monitorBatchTimeout.current) {
monitorBatchTimeout.current = setTimeout(flushPendingMonitorEvents, 50)
}
},
pbOptions
)
} catch (error) {
console.error("Failed to subscribe to monitors", error)
}
})()
return () => {
if (monitorBatchTimeout.current !== null) {
clearTimeout(monitorBatchTimeout.current)
monitorBatchTimeout.current = null
}
pendingMonitorEvents.current.clear()
unsubscribe?.()
}
}, [systemId])
return { monitors, isLoading }
}
interface UseNetworkMonitorStatsProps {
systemId: string
monitorId: string
chartTime: ChartTimes
enabled?: boolean
}
export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
const { systemId, monitorId, chartTime, enabled = true } = props
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])
// Fetch only the selected monitor's missing history.
useEffect(() => {
if (!enabled || chartTime === "1m") {
return
}
let cancelled = false
const { expectedInterval } = chartTimeData[chartTime]
const cachedMonitorStats = getCacheValue(monitorId, chartTime)
if (cachedMonitorStats.length) {
setMonitorStats(cachedMonitorStats)
const lastCreated = cachedMonitorStats.at(-1)?.created
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
return
}
}
fetchMonitorStats(monitorId, chartTime, cachedMonitorStats)
.then((newMonitorStats) => {
if (cancelled) return
setMonitorStats(appendCacheValue(monitorId, chartTime, newMonitorStats))
})
.catch((error) => {
if (!cancelled) console.error("Failed to fetch monitor stats:", error)
})
return () => {
cancelled = true
}
}, [monitorId, chartTime, enabled])
// subscribe to new per-monitor stats records; batch them into merged chart records
useEffect(() => {
if (!enabled || chartTime === "1m") {
return
}
let cancelled = false
let unsubscribe: (() => void) | undefined
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,
type: chartTimeData[chartTime].type,
}),
}
function flushPending() {
mergeBatchTimeout.current = null
const pending = pendingRaw.current
pendingRaw.current = new Map()
const merged = mergeMonitorStats(Array.from(pending.values()))
if (merged.length > 0) {
const newStats = appendCacheValue(monitorId, chartTime, merged)
setMonitorStats(newStats)
}
}
;(async () => {
try {
unsubscribe = await pb.collection<RawMonitorStatsRecord>("network_monitor_stats").subscribe(
"*",
(event) => {
if (cancelled || event.action !== "create") {
return
}
const rec = event.record
pendingRaw.current.set(`${rec.monitor}:${rec.created}`, rec)
if (!mergeBatchTimeout.current) {
mergeBatchTimeout.current = setTimeout(flushPending, 200)
}
},
pbOptions
)
if (cancelled) unsubscribe()
} catch (error) {
console.error("Failed to subscribe to monitor stats:", error)
}
})()
return () => {
cancelled = true
if (mergeBatchTimeout.current) {
clearTimeout(mergeBatchTimeout.current)
mergeBatchTimeout.current = null
}
pendingRaw.current.clear()
unsubscribe?.()
}
}, [monitorId, chartTime, enabled])
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
if (!enabled || chartTime !== "1m") {
return
}
let cancelled = false
let unsubscribe: (() => void) | undefined
pb.realtime
.subscribe(
`rt_metrics`,
(data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => {
const monitorStats = data.Monitors?.[monitorId]
if (cancelled || !monitorStats) return
const stats = { created: Date.now(), stats: { [monitorId]: monitorStats } }
const newStats = appendCacheValue(monitorId, "rt", [stats], 120)
setMonitorStats(newStats)
},
{ query: { system: systemId } }
)
.then((us) => {
unsubscribe = us
if (cancelled) unsubscribe()
})
return () => {
cancelled = true
unsubscribe?.()
}
}, [chartTime, systemId, monitorId, enabled])
return monitorStats
}
async function fetchMonitors(system?: string) {
try {
return await pb.collection<NetworkMonitorRecord>("network_monitors").getFullList({
fields: NETWORK_MONITOR_FIELDS,
filter: system ? pb.filter("system={:system}", { system }) : undefined,
})
} catch (error) {
toast({
title: "Error",
description: (error as Error)?.message,
variant: "destructive",
})
return []
}
}
function applyMonitorEvents(
monitors: NetworkMonitorRecord[],
events: Iterable<RecordSubscription<NetworkMonitorRecord>>,
systemId?: string
) {
const monitorById = new Map(monitors.map((monitor) => [monitor.id, monitor]))
const createdMonitors: NetworkMonitorRecord[] = []
for (const { action, record } of events) {
const matchesSystemScope = !systemId || record.system === systemId
if (action === "delete" || !matchesSystemScope) {
monitorById.delete(record.id)
continue
}
if (!monitorById.has(record.id)) {
createdMonitors.push(record)
}
monitorById.set(record.id, record)
}
const nextMonitors: NetworkMonitorRecord[] = []
for (let index = createdMonitors.length - 1; index >= 0; index -= 1) {
nextMonitors.push(createdMonitors[index])
}
for (const monitor of monitors) {
const nextMonitor = monitorById.get(monitor.id)
if (!nextMonitor) {
continue
}
nextMonitors.push(nextMonitor)
monitorById.delete(monitor.id)
}
return nextMonitors
}

View File

@@ -72,7 +72,7 @@ export const formatShortDate = (timestamp: string) => {
return shortDateFormatter.format(new Date(timestamp))
}
export const hourWithSeconds = (timestamp: string) => {
export const hourWithSeconds = (timestamp: string | number) => {
return hourWithSecondsFormatter.format(new Date(timestamp))
}
@@ -111,17 +111,18 @@ export const updateFavicon = (() => {
</linearGradient>
</defs>
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
${downCount > 0 &&
`
${
downCount > 0 &&
`
<circle cx="40" cy="50" r="22" fill="#f00"/>
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
`
}
}
</svg>
`
const blob = new Blob([svg], { type: "image/svg+xml" })
const url = URL.createObjectURL(blob)
; (document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
}
})()
@@ -198,6 +199,26 @@ export function decimalString(num: number, digits = 2) {
return formatter.format(num)
}
export function formatMicroseconds(microseconds: number, showDigits = true): string {
if (!Number.isFinite(microseconds)) {
return "-"
}
if (microseconds < 1000) {
return `${microseconds}μs`
}
if (microseconds < 1_000_000) {
const milliseconds = microseconds / 1000
const digits = milliseconds >= 10 ? 1 : 2
return `${decimalString(milliseconds, showDigits ? digits : 0)}ms`
}
const seconds = microseconds / 1_000_000
const digits = seconds >= 10 ? 1 : 2
return `${decimalString(seconds, showDigits ? digits : 0)}s`
}
/** Get value from local or session storage */
function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) {
const saved = storageInterface?.getItem(key)
@@ -365,12 +386,12 @@ export function formatDuration(
.join(" ")
}
/** Parse semver string into major, minor, and patch numbers
/** Parse semver string into major, minor, and patch numbers
* @example
* const semVer = "1.2.3"
* const { major, minor, patch } = parseSemVer(semVer)
* console.log(major, minor, patch) // 1, 2, 3
*/
*/
export const parseSemVer = (semVer = ""): SemVer => {
// if (semVer.startsWith("v")) {
// semVer = semVer.slice(1)
@@ -393,6 +414,12 @@ export function compareSemVer(a: SemVer, b: SemVer) {
return a.patch - b.patch
}
const MIN_NETWORK_MONITOR_AGENT_VERSION = parseSemVer("0.20.0")
export function supportsNetworkMonitors(system: Pick<SystemRecord, "info">) {
return compareSemVer(parseSemVer(system.info?.v), MIN_NETWORK_MONITOR_AGENT_VERSION) >= 0
}
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>
@@ -422,10 +449,22 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
}) as T
}
/** Get the visual width of a string, accounting for full-width characters */
export function getVisualStringWidth(str: string): number {
const visualWidthCache = new Map<string, number>()
/** Get the visual width of a string, accounting for full-width and narrow punctuation characters.
* Don't use for monospaced fonts, use .length instead
*/
function getVisualStringWidth(str: string): number {
const cached = visualWidthCache.get(str)
if (cached !== undefined) {
return cached
}
let width = 0
for (const char of str) {
if (char === ".") {
width += 0.7
continue
}
const code = char.codePointAt(0) || 0
// Hangul Jamo and Syllables are often slightly thinner than Hanzi/Kanji
if ((code >= 0x1100 && code <= 0x115f) || (code >= 0xac00 && code <= 0xd7af)) {
@@ -443,16 +482,41 @@ export function getVisualStringWidth(str: string): number {
code > 0xffff // Emojis and other supplementary plane characters
width += isFullWidth ? 2 : 1
}
visualWidthCache.set(str, width)
return width
}
/** Compare the visual width of two strings imprecisely */
export function isVisuallyLonger(str1: string, str2: string): boolean {
return getVisualStringWidth(str1) > getVisualStringWidth(str2)
}
/** Parses a filter string into OR'd groups of AND'd terms: "a b, c" -> [["a","b"], ["c"]] */
export function parseFilterGroups(value: string): string[][] {
return value
.toLowerCase()
.split(",")
.map((group) => group.trim().split(" ").filter((term) => term.length > 0))
.filter((terms) => terms.length > 0)
}
/** True if every term in at least one OR'd group is found in searchString. */
export function matchesFilterGroups(searchString: string, groups: string[][]): boolean {
return groups.some((terms) => terms.every((term) => searchString.includes(term)))
}
/** Format seconds to hours, minutes, or seconds */
export function secondsToString(seconds: number, unit: "hour" | "minute" | "day"): string {
const count = Math.floor(seconds / (unit === "hour" ? 3600 : unit === "minute" ? 60 : 86400))
const countString = count.toLocaleString()
switch (unit) {
case "minute":
return plural(count, { one: `${countString} minute`, few: `${countString} minutes`, many: `${countString} minutes`, other: `${countString} minutes` })
return plural(count, {
one: `${countString} minute`,
few: `${countString} minutes`,
many: `${countString} minutes`,
other: `${countString} minutes`,
})
case "hour":
return plural(count, { one: `${countString} hour`, other: `${countString} hours` })
case "day":
@@ -469,4 +533,4 @@ export function secondsToUptimeString(seconds: number): string {
} else {
return secondsToString(seconds, "day")
}
}
}

View File

@@ -30,6 +30,7 @@ const LoginPage = lazy(() => import("@/components/login/login.tsx"))
const Home = lazy(() => import("@/components/routes/home.tsx"))
const Containers = lazy(() => import("@/components/routes/containers.tsx"))
const Smart = lazy(() => import("@/components/routes/smart.tsx"))
const Monitors = lazy(() => import("@/components/routes/monitors.tsx"))
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
@@ -79,6 +80,8 @@ const App = memo(() => {
return <Containers />
} else if (page.route === "smart") {
return <Smart />
} else if (page.route === "monitors") {
return <Monitors />
} else if (page.route === "settings") {
return <Settings />
}

View File

@@ -307,6 +307,7 @@ export interface AlertRecord extends RecordModel {
}
export interface AlertsHistoryRecord extends RecordModel {
monitor_name?: string
alert: string
user: string
system: string
@@ -393,11 +394,9 @@ export interface SemVer {
export interface ChartData {
agentVersion: SemVer
systemStats: SystemStatsRecord[]
containerData: ChartDataContainer[]
systemStats?: SystemStatsRecord[]
containerData?: ChartDataContainer[]
orientation: "right" | "left"
ticks: number[]
domain: number[]
chartTime: ChartTimes
}
@@ -414,6 +413,8 @@ export interface AlertInfo {
singleDesc?: () => string
/** Hides the duration slider for alerts that fire on first observation */
noDuration?: boolean
/** Hides the threshold control for binary alerts */
noThreshold?: boolean
/** Description shown instead of numeric threshold and duration values */
triggeredDesc?: () => string
/** Additional information that remains visible while the alert is enabled */
@@ -632,3 +633,52 @@ export interface UpdateInfo {
v: string // new version
url: string // url to new version
}
export interface NetworkMonitorRecord {
id: string
system: string
target: string
protocol: "icmp" | "tcp" | "http" | "dns"
port: number
res: number
resMin1h: number
resMax1h: number
resAvg1h: number
loss: number
loss1h: number
interval: number
enabled: boolean
updated: string
}
/** Response times in microseconds and packet loss percentage (0-100). */
export interface MonitorStats {
res_avg: number
res_min: number
res_max: number
loss: number
}
/** Raw per-monitor record stored in the DB. */
export interface RawMonitorStatsRecord {
res_min: number
res_max: number
total_count: number
success_count: number
res_sum: number
id?: string
type?: string
monitor: string
created: number // unix timestamp (ms)
}
/**
* Merged stats record keyed by monitor ID, used by chart components.
* Constructed from multiple RawMonitorStatsRecord entries sharing the same timestamp.
*/
export interface NetworkMonitorStatsRecord {
id?: string
type?: string
stats: Record<string, MonitorStats>
created: number // unix timestamp (ms) for Recharts xAxis
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { getMonitorStats } from "../src/lib/network-monitor-utils"
describe("monitor stats derived from stored counts", () => {
test("retains probe weights and response precision", () => {
const stats = getMonitorStats({
monitor: "monitor1",
created: 1000,
res_min: 5,
res_max: 20,
total_count: 7,
success_count: 6,
res_sum: 61,
})
expect(stats.res_avg).toBeCloseTo(10.1666667)
expect(stats.loss).toBeCloseTo(14.2857143)
expect(stats.res_min).toBe(5)
expect(stats.res_max).toBe(20)
})
test.each([
{ total_count: 3, success_count: 0, loss: 100 },
{ total_count: 0, success_count: 0, loss: 0 },
{ total_count: 1, success_count: 1, loss: 0 },
])("handles zero sums with $total_count attempts and $success_count successes", ({ loss, ...counts }) => {
const stats = getMonitorStats({
monitor: "monitor1",
created: 1000,
res_min: 0,
res_max: 0,
res_sum: 0,
...counts,
})
expect(stats).toEqual({ res_avg: 0, res_min: 0, res_max: 0, loss })
})
})