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 & { interval: number } type BulkMonitorLineSource = Pick 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 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 onChange: (ids: Set) => void disabled?: boolean className?: string }) { const systems = useStore($systems) const { t } = useLingui() const [search, setSearch] = useState("") const searchRef = useRef(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(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 ( setSearch("")}> { 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" >
{ event.preventDefault() selectFiltered(true) }} > {query ? Select matches : Select all} { event.preventDefault() selectFiltered(false) }} > {query ? Clear matches : Clear all}
{t`${selectedSystemIds.size} selected`}
{filteredSystems.length === 0 && ( No systems found. )} {filteredSystems.map((sys) => ( 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" > {sys.name} ))}
) } 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>(new Set()) const bulkFormRef = useRef(null) const { toast } = useToast() const { t } = useLingui() const resetBulkForm = () => { setBulkInput("") } const openBulkAdd = (selectedSystemIds?: Set) => { 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 ( <>
openBulkAdd()}> Bulk Add
{ setOpen(nextOpen) }} > { setBulkOpen(nextOpen) if (!nextOpen) { resetBulkForm() } }} > Bulk Add {{ foo: t`Network Monitors` }} target[,protocol[,port[,interval]]]
{!systemId && (
)}