import { 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, 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 } from "lucide-react" import { useToast } from "@/components/ui/use-toast" import { $systems } from "@/lib/stores" import type { NetworkProbeRecord } from "@/types" import * as v from "valibot" type ProbeProtocol = "icmp" | "tcp" | "http" type ProbeValues = { system: string target: string protocol: ProbeProtocol port: number interval: string name?: string } type NormalizedProbeValues = Omit & { interval: number } const ProbeProtocolSchema = v.picklist(["icmp", "tcp", "http"]) const ProbeIntervalSchema = v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600)) // Both the single-probe form and the bulk importer flow through this schema so // defaults and HTTP target normalization stay in one place. const NormalizedProbeValuesSchema = v.pipe( v.object({ target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")), protocol: ProbeProtocolSchema, port: v.number(), interval: ProbeIntervalSchema, name: v.optional(v.pipe(v.string(), v.trim())), }), v.transform((input): NormalizedProbeValues => { let { protocol, port } = input if (protocol === "icmp") { port = 0 } else if ((protocol === "tcp" || protocol === "http") && !port) { port = 443 } return { // HTTP probes may be entered as bare hostnames, so normalize them to a // scheme-bearing URL before the payload is sent to PocketBase. target: protocol === "http" ? normalizeHttpTarget(input.target, port) : input.target, protocol, port, interval: input.interval, name: input.name || undefined, } }), v.forward( v.check((input) => { if (input.protocol === "icmp") { 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 BulkProbeSchema = 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())), name: v.optional(v.pipe(v.string(), v.trim())), }) function normalizeHttpTarget(target: string, port: number) { if (/^https?:\/\//i.test(target)) { return target } return `${port === 443 ? "https" : "http"}://${target}` } function buildProbePayload(values: ProbeValues) { const normalizedValues = v.safeParse(NormalizedProbeValuesSchema, values) if (!normalizedValues.success) { throw new Error(normalizedValues.issues[0]?.message || "Invalid probe") } const payload = { system: values.system, enabled: true, ...normalizedValues.output, } const trimmedName = normalizedValues.output.name?.trim() const targetName = normalizedValues.output.target.replace(/^https?:\/\//i, "") if (trimmedName) { payload.name = trimmedName } else if (targetName !== normalizedValues.output.target) { payload.name = targetName } else { payload.name = "" } return payload } function parseBulkProbeLine(line: string, lineNumber: number, system: string) { const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",") const parsed = v.safeParse(BulkProbeSchema, { target: rawTarget, protocol: rawProtocol, port: rawPort, interval: rawInterval, name: rawName.join(","), }) if (!parsed.success) { throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid probe entry"}`) } return buildProbePayload({ system, target: parsed.output.target, protocol: (parsed.output.protocol?.toLowerCase() || (/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as ProbeProtocol, port: parsed.output.port ? Number(parsed.output.port) : 0, interval: parsed.output.interval || "30", name: parsed.output.name || undefined, }) } export function AddProbeDialog({ systemId }: { systemId?: string }) { const [open, setOpen] = useState(false) const [bulkOpen, setBulkOpen] = useState(false) const [bulkInput, setBulkInput] = useState("") const [bulkLoading, setBulkLoading] = useState(false) const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("") const bulkFormRef = useRef(null) const { toast } = useToast() const { t } = useLingui() const systems = useStore($systems) const resetBulkForm = () => { setBulkInput("") // setBulkSelectedSystemId("") } const openBulkAdd = (selectedSystemId?: string) => { if (!systemId && selectedSystemId) { setBulkSelectedSystemId(selectedSystemId) } 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 system = systemId ?? bulkSelectedSystemId if (!system) { throw new Error("Select a system.") } const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim()) if (!rawLines.length) { throw new Error("Enter at least one probe.") } const payloads = rawLines.map((line, index) => parseBulkProbeLine(line, index + 1, system)) closedForSubmit = true let batch = pb.createBatch() let inBatch = 0 for (const payload of payloads) { batch.collection("network_probes").create(payload) inBatch++ if (inBatch > 20) { await batch.send() batch = pb.createBatch() inBatch = 0 } } if (inBatch) { await batch.send() } resetBulkForm() toast({ title: t`Probes created`, description: `${payloads.length} probe(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(systemId)}> Bulk Add
{ setOpen(nextOpen) }} > { setBulkOpen(nextOpen) if (!nextOpen) { resetBulkForm() } }} > Bulk Add {{ foo: t`Network Probes` }} target[,protocol[,port[,interval[,name]]]] - TCP/HTTP default to port 443.
{!systemId && (
)}