This commit is contained in:
henrygd
2026-04-23 02:28:03 -04:00
parent 0d440e5fb9
commit 9f7c1b22bb
4 changed files with 468 additions and 121 deletions

View File

@@ -9,17 +9,30 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 { PlusIcon } from "lucide-react"
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 * as v from "valibot"
type ProbeProtocol = "icmp" | "tcp" | "http"
type ProbeValues = {
system: string
target: string
protocol: ProbeProtocol
port: number
interval: string
name?: string
}
const Schema = v.object({
system: v.string(),
target: v.string(),
@@ -30,15 +43,78 @@ const Schema = v.object({
name: v.optional(v.string()),
})
function buildProbePayload(values: ProbeValues) {
const normalizedPort = (values.protocol === "tcp" || values.protocol === "http") && !values.port ? 443 : values.port
const payload = v.parse(Schema, {
system: values.system,
target: values.target,
protocol: values.protocol,
port: normalizedPort,
interval: values.interval,
enabled: true,
})
const trimmedName = values.name?.trim()
const targetName = values.target.replace(/^https?:\/\//i, "")
if (trimmedName) {
payload.name = trimmedName
} else if (targetName !== values.target) {
payload.name = targetName
}
return payload
}
function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",")
const target = rawTarget.trim()
if (!target) {
throw new Error(`Line ${lineNumber}: target is required`)
}
const inferredProtocol: ProbeProtocol = /^https?:\/\//i.test(target) ? "http" : "icmp"
const protocolValue = rawProtocol.trim().toLowerCase() || inferredProtocol
if (protocolValue !== "icmp" && protocolValue !== "tcp" && protocolValue !== "http") {
throw new Error(`Line ${lineNumber}: protocol must be icmp, tcp, or http`)
}
const portValue = rawPort.trim()
if (protocolValue === "tcp") {
const port = portValue ? Number(portValue) : 443
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Line ${lineNumber}: TCP entries require a port between 1 and 65535`)
}
return buildProbePayload({
system,
target,
protocol: "tcp",
port,
interval: rawInterval.trim() || "30",
name: rawName.join(",").trim() || undefined,
})
}
return buildProbePayload({
system,
target,
protocol: protocolValue,
port: 0,
interval: rawInterval.trim() || "30",
name: rawName.join(",").trim() || undefined,
})
}
export function AddProbeDialog({ systemId }: { systemId?: string }) {
const [open, setOpen] = useState(false)
const [bulkOpen, setBulkOpen] = useState(false)
const [protocol, setProtocol] = useState<string>("icmp")
const [target, setTarget] = useState("")
const [port, setPort] = useState("")
const [probeInterval, setProbeInterval] = useState("30")
const [name, setName] = useState("")
const [loading, setLoading] = useState(false)
const [bulkInput, setBulkInput] = useState("")
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState("")
const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("")
const systems = useStore($systems)
const { toast } = useToast()
const { t } = useLingui()
@@ -53,24 +129,37 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
setSelectedSystemId("")
}
const handleSubmit = async (e: React.FormEvent) => {
const resetBulkForm = () => {
setBulkInput("")
setBulkSelectedSystemId("")
}
const openBulkAdd = () => {
if (!systemId && selectedSystemId) {
setBulkSelectedSystemId(selectedSystemId)
}
setOpen(false)
setBulkOpen(true)
}
const openAdd = () => {
setBulkOpen(false)
setOpen(true)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const payload = v.parse(Schema, {
const payload = buildProbePayload({
system: systemId ?? selectedSystemId,
target,
protocol,
protocol: protocol as ProbeProtocol,
port: protocol === "tcp" ? Number(port) : 0,
interval: probeInterval,
enabled: true,
name,
})
if (name) {
payload.name = name
} else if (targetName !== target) {
payload.name = targetName
}
await pb.collection("network_probes").create(payload)
resetForm()
setOpen(false)
@@ -81,116 +170,244 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
}
}
async function handleBulkSubmit(e: React.FormEvent) {
e.preventDefault()
setBulkLoading(true)
let closedForSubmit = false
try {
const system = systemId ?? bulkSelectedSystemId
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))
setBulkOpen(false)
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<PlusIcon className="size-4 me-1" />
<>
<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`Probe` }}</Trans>
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure response monitoring from this agent.</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
{!systemId && (
<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 probe 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={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure response monitoring from this agent.</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
{!systemId && (
<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.map((sys) => (
<SelectItem key={sys.id} value={sys.id}>
{sys.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<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.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" ? "https://example.com" : "1.1.1.1"}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Protocol</Trans>
</Label>
<Select value={protocol} onValueChange={setProtocol}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
</div>
{protocol === "tcp" && (
<div className="grid gap-2">
<Label>
<Trans>Port</Trans>
<Trans>Target</Trans>
</Label>
<Input
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="443"
min={1}
max={65535}
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"}
required
/>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Interval (seconds)</Trans>
</Label>
<Input
type="number"
value={probeInterval}
onChange={(e) => setProbeInterval(e.target.value)}
min={1}
max={3600}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Name (optional)</Trans>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={targetName || t`e.g. Cloudflare DNS`}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="grid gap-2">
<Label>
<Trans>Protocol</Trans>
</Label>
<Select value={protocol} onValueChange={setProtocol}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="http">HTTP</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}
required
/>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Interval (seconds)</Trans>
</Label>
<Input
type="number"
value={probeInterval}
onChange={(e) => setProbeInterval(e.target.value)}
min={1}
max={3600}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Name (optional)</Trans>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={targetName || t`e.g. Cloudflare DNS`}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Sheet open={bulkOpen} onOpenChange={setBulkOpen}>
<SheetContent className="w-full sm:max-w-xl gap-0">
<SheetHeader className="border-b">
<SheetTitle>
<Trans>Bulk Add {{ foo: t`Network Probes` }}</Trans>
</SheetTitle>
<SheetDescription>
<Trans>
Paste one probe per line. See{" "}
<a href={"#bulk-add-probes-docs"} className="underline underline-offset-2">
the documentation
</a>
.
</Trans>
</SheetDescription>
</SheetHeader>
<form onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
<div className="flex-1 space-y-4 overflow-auto p-4">
{!systemId && (
<div className="grid gap-2">
<Label>
<Trans>System</Trans>
</Label>
<Select value={bulkSelectedSystemId} onValueChange={setBulkSelectedSystemId} required>
<SelectTrigger>
<SelectValue placeholder={t`Select a system`} />
</SelectTrigger>
<SelectContent>
{systems.map((sys) => (
<SelectItem key={sys.id} value={sys.id}>
{sys.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="bulk-probes">
<Trans>Entries</Trans>
</Label>
<Textarea
id="bulk-probes"
value={bulkInput}
onChange={(e) => setBulkInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
handleBulkSubmit(e)
}
}}
className="h-120 font-mono text-sm bg-muted/40"
style={{ maxHeight: `calc(100vh - 20rem)` }}
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60,Homepage"].join("\n")}
required
/>
<p className="text-xs text-muted-foreground">
target[,protocol[,port[,interval[,name]]]] TCP and HTTP default to port 443.
</p>
</div>
</div>
<SheetFooter className="border-t">
<Button type="submit" disabled={bulkLoading || (!systemId && !bulkSelectedSystemId)}>
<Trans>Add {{ foo: t`Network Probes` }}</Trans>
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
</>
)
}