mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 08:57:48 +02:00
feat: add network monitors (ICMP/TCP/HTTP/DNS) (#2266)
Co-authored-by: xiaomiku01 <xiaomiku01@outlook.com> Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const routes = {
|
||||
home: "/",
|
||||
containers: "/containers",
|
||||
smart: "/smart",
|
||||
monitors: "/monitors",
|
||||
system: `/system/:id`,
|
||||
settings: `/settings/:name?`,
|
||||
forgot_password: `/forgot-password`,
|
||||
|
||||
33
internal/site/src/components/routes/monitors.tsx
Normal file
33
internal/site/src/components/routes/monitors.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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") && (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}%`
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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")
|
||||
|
||||
17
internal/site/src/lib/network-monitor-utils.ts
Normal file
17
internal/site/src/lib/network-monitor-utils.ts
Normal 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}`
|
||||
}
|
||||
@@ -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("")
|
||||
|
||||
@@ -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)
|
||||
|
||||
351
internal/site/src/lib/use-network-monitors.ts
Normal file
351
internal/site/src/lib/use-network-monitors.ts
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
58
internal/site/src/types.d.ts
vendored
58
internal/site/src/types.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
36
internal/site/tests/network-monitor-utils.test.ts
Normal file
36
internal/site/tests/network-monitor-utils.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user