This commit is contained in:
henrygd
2026-04-23 21:34:56 -04:00
parent 9f7c1b22bb
commit e154123511
6 changed files with 321 additions and 197 deletions

View File

@@ -1,8 +1,6 @@
package hub package hub
import ( import (
"strconv"
"github.com/henrygd/beszel/internal/entities/probe" "github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/hub/systems"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -10,9 +8,7 @@ import (
// generateProbeID creates a stable hash ID for a probe based on its configuration and the system it belongs to. // generateProbeID creates a stable hash ID for a probe based on its configuration and the system it belongs to.
func generateProbeID(systemId string, config probe.Config) string { func generateProbeID(systemId string, config probe.Config) string {
intervalStr := strconv.FormatUint(uint64(config.Interval), 10) return systems.MakeStableHashId(systemId, config.Target, config.Protocol)
portStr := strconv.FormatUint(uint64(config.Port), 10)
return systems.MakeStableHashId(systemId, config.Protocol, config.Target, portStr, intervalStr)
} }
// bindNetworkProbesEvents keeps probe records and agent probe state in sync. // bindNetworkProbesEvents keeps probe records and agent probe state in sync.
@@ -27,7 +23,7 @@ func bindNetworkProbesEvents(hub *Hub) {
}) })
// sync probe to agent on creation and persist the first result immediately when available // sync probe to agent on creation and persist the first result immediately when available
hub.OnRecordCreateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error { hub.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
err := e.Next() err := e.Next()
if err != nil { if err != nil {
return err return err
@@ -47,10 +43,24 @@ func bindNetworkProbesEvents(hub *Hub) {
if err := e.App.SaveNoValidate(e.Record); err != nil { if err := e.App.SaveNoValidate(e.Record); err != nil {
hub.Logger().Warn("failed to save initial probe result", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err) hub.Logger().Warn("failed to save initial probe result", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
} }
return nil return e.Next()
}) })
// On API update requests, if the probe config changed in a way that requires a new ID, we will create a new
// record with the new ID and delete the old one. Otherwise, we will just update the existing probe on the agent.
hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error { hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
systemID := e.Record.GetString("system")
ID := generateProbeID(systemID, *probeConfigFromRecord(e.Record))
if ID != e.Record.Id {
newRecord := copyProbeToNewRecord(e.Record, ID)
if err := e.App.Save(newRecord); err != nil {
return err
}
if err := e.App.Delete(e.Record); err != nil {
return err
}
return nil
}
err := e.Next() err := e.Next()
if err != nil { if err != nil {
return err return err
@@ -67,15 +77,11 @@ func bindNetworkProbesEvents(hub *Hub) {
}) })
// sync probe to agent on delete // sync probe to agent on delete
hub.OnRecordDeleteRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error { hub.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
err := e.Next()
if err != nil {
return err
}
if err := hub.deleteNetworkProbe(e.Record); err != nil { if err := hub.deleteNetworkProbe(e.Record); err != nil {
hub.Logger().Warn("failed to delete probe on agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err) hub.Logger().Warn("failed to delete probe on agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
} }
return nil return e.Next()
}) })
} }
@@ -99,6 +105,17 @@ func setProbeResultFields(record *core.Record, result probe.Result) {
record.Set("loss1h", result.Get(4)) record.Set("loss1h", result.Get(4))
} }
// copyProbeToNewRecord creates a new record with the same field values as the old one.
// This is used when the probe config changes in a way that requires a new ID, so we need
// to create a new record with the new ID and delete the old one.
func copyProbeToNewRecord(oldRecord *core.Record, newID string) *core.Record {
collection := oldRecord.Collection()
newRecord := core.NewRecord(collection)
newRecord.Load(oldRecord.FieldsData())
newRecord.Set("id", newID)
return newRecord
}
// upsertNetworkProbe applies the record's probe config to the target system. // upsertNetworkProbe applies the record's probe config to the target system.
func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) (*probe.Result, error) { func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) (*probe.Result, error) {
systemID := record.GetString("system") systemID := record.GetString("system")

View File

@@ -23,7 +23,7 @@ func TestGenerateProbeID(t *testing.T) {
Port: 80, Port: 80,
Interval: 60, Interval: 60,
}, },
expected: "d5f27931", expected: "a20a5827",
}, },
{ {
name: "HTTP probe on example.com with different system ID", name: "HTTP probe on example.com with different system ID",
@@ -34,7 +34,7 @@ func TestGenerateProbeID(t *testing.T) {
Port: 80, Port: 80,
Interval: 60, Interval: 60,
}, },
expected: "6f8b17f1", expected: "ab602ae7",
}, },
{ {
name: "Same probe, different interval", name: "Same probe, different interval",
@@ -45,7 +45,7 @@ func TestGenerateProbeID(t *testing.T) {
Port: 80, Port: 80,
Interval: 120, Interval: 120,
}, },
expected: "6d4baf8", expected: "ab602ae7",
}, },
{ {
name: "ICMP probe on 1.1.1.1", name: "ICMP probe on 1.1.1.1",
@@ -56,7 +56,7 @@ func TestGenerateProbeID(t *testing.T) {
Port: 0, Port: 0,
Interval: 10, Interval: 10,
}, },
expected: "80b5836b", expected: "6d13a4a4",
}, { }, {
name: "ICMP probe on 1.1.1.1 with different system ID", name: "ICMP probe on 1.1.1.1 with different system ID",
systemID: "sys4567", systemID: "sys4567",
@@ -66,7 +66,7 @@ func TestGenerateProbeID(t *testing.T) {
Port: 0, Port: 0,
Interval: 10, Interval: 10,
}, },
expected: "a6652680", expected: "ddd6c81",
}, },
} }

View File

@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"log/slog"
"math/rand" "math/rand"
"net" "net"
"strings" "strings"
@@ -325,7 +324,6 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst
// If realtime updates are active, we save via PocketBase records to trigger realtime events. // If realtime updates are active, we save via PocketBase records to trigger realtime events.
// Otherwise we can do a more efficient direct update via SQL // Otherwise we can do a more efficient direct update via SQL
realtimeActive := utils.RealtimeActiveForCollection(app, collectionName, func(filterQuery string) bool { realtimeActive := utils.RealtimeActiveForCollection(app, collectionName, func(filterQuery string) bool {
slog.Info("Checking realtime subscription filter for network probes", "filterQuery", filterQuery)
return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId) return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId)
}) })
@@ -335,35 +333,10 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst
if !realtimeActive { if !realtimeActive {
db = app.DB() db = app.DB()
nowString = time.Now().UTC().Format(types.DefaultDateLayout) nowString = time.Now().UTC().Format(types.DefaultDateLayout)
sql := fmt.Sprintf("UPDATE %s SET resAvg={:res}, resMin1h={:resMin1h}, resMax1h={:resMax1h}, resAvg1h={:resAvg1h}, loss1h={:loss1h}, updated={:updated} WHERE id={:id}", collectionName) sql := fmt.Sprintf("UPDATE %s SET res={:res}, resMin1h={:resMin1h}, resMax1h={:resMax1h}, resAvg1h={:resAvg1h}, loss1h={:loss1h}, updated={:updated} WHERE id={:id}", collectionName)
updateQuery = db.NewQuery(sql) updateQuery = db.NewQuery(sql)
} }
// insert network probe stats records
switch realtimeActive {
case true:
collection, _ := app.FindCachedCollectionByNameOrId("network_probe_stats")
record := core.NewRecord(collection)
record.Set("system", systemId)
record.Set("stats", data)
record.Set("type", "1m")
err = app.SaveNoValidate(record)
default:
if dataJSON, marshalErr := json.Marshal(data); marshalErr == nil {
sql := "INSERT INTO network_probe_stats (system, stats, type, created) VALUES ({:system}, {:stats}, {:type}, {:created})"
insertQuery := db.NewQuery(sql)
_, err = insertQuery.Bind(dbx.Params{
"system": systemId,
"stats": dataJSON,
"type": "1m",
"created": nowString,
}).Execute()
}
}
if err != nil {
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
}
// update network_probes records // update network_probes records
for id, values := range data { for id, values := range data {
switch realtimeActive { switch realtimeActive {
@@ -394,6 +367,31 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst
} }
} }
// insert network probe stats records
switch realtimeActive {
case true:
collection, _ := app.FindCachedCollectionByNameOrId("network_probe_stats")
record := core.NewRecord(collection)
record.Set("system", systemId)
record.Set("stats", data)
record.Set("type", "1m")
err = app.SaveNoValidate(record)
default:
var statsJson types.JSONRaw
if err := statsJson.Scan(data); err == nil {
insertQuery := db.NewQuery("INSERT INTO network_probe_stats (system, stats, type, created) VALUES ({:system}, {:stats}, {:type}, {:created})")
_, err = insertQuery.Bind(dbx.Params{
"system": systemId,
"stats": statsJson,
"type": "1m",
"created": nowString,
}).Execute()
}
}
if err != nil {
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
}
return nil return nil
} }

View File

@@ -12,10 +12,17 @@ import {
ClockIcon, ClockIcon,
NetworkIcon, NetworkIcon,
RefreshCwIcon, RefreshCwIcon,
PenBoxIcon,
} from "lucide-react" } from "lucide-react"
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import type { NetworkProbeRecord } from "@/types" import type { NetworkProbeRecord } from "@/types"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
import { toast } from "../ui/use-toast" import { toast } from "../ui/use-toast"
@@ -36,7 +43,11 @@ async function deleteProbe(id: string) {
} }
} }
export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<NetworkProbeRecord>[] { export function getProbeColumns(
longestName = 0,
longestTarget = 0,
onEdit?: (probe: NetworkProbeRecord) => void
): ColumnDef<NetworkProbeRecord>[] {
return [ return [
{ {
id: "name", id: "name",
@@ -177,6 +188,16 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}> <DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
onEdit?.(row.original)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()

View File

@@ -40,7 +40,7 @@ import { $allSystemsById } from "@/lib/stores"
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils" import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
import { Trash2Icon } from "lucide-react" import { Trash2Icon } from "lucide-react"
import type { NetworkProbeRecord } from "@/types" import type { NetworkProbeRecord } from "@/types"
import { AddProbeDialog } from "./probe-dialog" import { AddProbeDialog, EditProbeDialog } from "./probe-dialog"
export default function NetworkProbesTableNew({ export default function NetworkProbesTableNew({
systemId, systemId,
@@ -59,6 +59,7 @@ export default function NetworkProbesTableNew({
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [globalFilter, setGlobalFilter] = useState("") const [globalFilter, setGlobalFilter] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
const [editingProbe, setEditingProbe] = useState<NetworkProbeRecord>()
const { toast } = useToast() const { toast } = useToast()
const canManageProbes = !isReadOnlyUser() const canManageProbes = !isReadOnlyUser()
@@ -74,7 +75,7 @@ export default function NetworkProbesTableNew({
// Filter columns based on whether systemId is provided // Filter columns based on whether systemId is provided
const columns = useMemo(() => { const columns = useMemo(() => {
let columns = getProbeColumns(longestName, longestTarget) let columns = getProbeColumns(longestName, longestTarget, setEditingProbe)
columns = systemId ? columns.filter((col) => col.id !== "system") : columns columns = systemId ? columns.filter((col) => col.id !== "system") : columns
columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions") columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions")
if (!canManageProbes) { if (!canManageProbes) {
@@ -233,6 +234,18 @@ export default function NetworkProbesTableNew({
/> />
)} )}
{canManageProbes ? <AddProbeDialog systemId={systemId} /> : null} {canManageProbes ? <AddProbeDialog systemId={systemId} /> : null}
{canManageProbes ? (
<EditProbeDialog
systemId={systemId}
probe={editingProbe}
open={!!editingProbe}
setOpen={(open) => {
if (!open) {
setEditingProbe(undefined)
}
}}
/>
) : null}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -20,6 +20,7 @@ import { Textarea } from "@/components/ui/textarea"
import { ChevronDownIcon, ListIcon } from "lucide-react" import { ChevronDownIcon, ListIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores" import { $systems } from "@/lib/stores"
import type { NetworkProbeRecord } from "@/types"
import * as v from "valibot" import * as v from "valibot"
type ProbeProtocol = "icmp" | "tcp" | "http" type ProbeProtocol = "icmp" | "tcp" | "http"
@@ -59,6 +60,8 @@ function buildProbePayload(values: ProbeValues) {
payload.name = trimmedName payload.name = trimmedName
} else if (targetName !== values.target) { } else if (targetName !== values.target) {
payload.name = targetName payload.name = targetName
} else {
payload.name = ""
} }
return payload return payload
} }
@@ -105,36 +108,19 @@ function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
export function AddProbeDialog({ systemId }: { systemId?: string }) { export function AddProbeDialog({ systemId }: { systemId?: string }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [bulkOpen, setBulkOpen] = 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 [bulkInput, setBulkInput] = useState("")
const [bulkLoading, setBulkLoading] = useState(false) const [bulkLoading, setBulkLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState("")
const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("") const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("")
const systems = useStore($systems)
const { toast } = useToast() const { toast } = useToast()
const { t } = useLingui() const { t } = useLingui()
const targetName = target.replace(/^https?:\/\//, "") const systems = useStore($systems)
const resetForm = () => {
setProtocol("icmp")
setTarget("")
setPort("")
setProbeInterval("30")
setName("")
setSelectedSystemId("")
}
const resetBulkForm = () => { const resetBulkForm = () => {
setBulkInput("") setBulkInput("")
setBulkSelectedSystemId("") setBulkSelectedSystemId("")
} }
const openBulkAdd = () => { const openBulkAdd = (selectedSystemId?: string) => {
if (!systemId && selectedSystemId) { if (!systemId && selectedSystemId) {
setBulkSelectedSystemId(selectedSystemId) setBulkSelectedSystemId(selectedSystemId)
} }
@@ -147,29 +133,6 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
setOpen(true) setOpen(true)
} }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const payload = buildProbePayload({
system: systemId ?? selectedSystemId,
target,
protocol: protocol as ProbeProtocol,
port: protocol === "tcp" ? Number(port) : 0,
interval: probeInterval,
name,
})
await pb.collection("network_probes").create(payload)
resetForm()
setOpen(false)
} catch (err: unknown) {
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
} finally {
setLoading(false)
}
}
async function handleBulkSubmit(e: React.FormEvent) { async function handleBulkSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setBulkLoading(true) setBulkLoading(true)
@@ -227,119 +190,31 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={openBulkAdd}> <DropdownMenuItem onClick={() => openBulkAdd(systemId)}>
<ListIcon className="size-4 me-2" /> <ListIcon className="size-4 me-2" />
<Trans>Bulk Add</Trans> <Trans>Bulk Add</Trans>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog
<DialogContent className="max-w-md"> open={open}
<DialogHeader> onOpenChange={(nextOpen) => {
<DialogTitle> setOpen(nextOpen)
<Trans>Add {{ foo: t`Network Probe` }}</Trans> }}
</DialogTitle> >
<DialogDescription> {open && <ProbeDialogContent setOpen={setOpen} systemId={systemId} onOpenBulkAdd={openBulkAdd} />}
<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>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>
</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> </Dialog>
<Sheet open={bulkOpen} onOpenChange={setBulkOpen}> <Sheet
open={bulkOpen}
onOpenChange={(nextOpen) => {
setBulkOpen(nextOpen)
if (!nextOpen) {
resetBulkForm()
}
}}
>
<SheetContent className="w-full sm:max-w-xl gap-0"> <SheetContent className="w-full sm:max-w-xl gap-0">
<SheetHeader className="border-b"> <SheetHeader className="border-b">
<SheetTitle> <SheetTitle>
@@ -411,3 +286,203 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
</> </>
) )
} }
export function EditProbeDialog({
open,
setOpen,
systemId,
probe,
}: {
open: boolean
setOpen: (open: boolean) => void
systemId?: string
probe?: NetworkProbeRecord
}) {
if (!probe) {
return null
}
return (
<Dialog open={open} onOpenChange={setOpen}>
{open && <ProbeDialogContent setOpen={setOpen} systemId={systemId} probe={probe} />}
</Dialog>
)
}
function ProbeDialogContent({
setOpen,
systemId,
probe,
onOpenBulkAdd,
}: {
setOpen: (open: boolean) => void
systemId?: string
probe?: NetworkProbeRecord
onOpenBulkAdd?: (selectedSystemId?: string) => void
}) {
const [protocol, setProtocol] = useState<ProbeProtocol>(probe?.protocol ?? "icmp")
const [target, setTarget] = useState(probe?.target ?? "")
const [port, setPort] = useState(probe?.protocol === "tcp" && probe.port ? String(probe.port) : "")
const [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? 30))
const [name, setName] = useState(probe?.name ?? "")
const [loading, setLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState(probe?.system ?? "")
const systems = useStore($systems)
const { toast } = useToast()
const { t } = useLingui()
const isEditing = !!probe
const targetName = target.replace(/^https?:\/\//, "")
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const payload = buildProbePayload({
system: systemId ?? selectedSystemId,
target,
protocol,
port: protocol === "tcp" ? Number(port) : 0,
interval: probeInterval,
name,
})
if (probe) {
await pb.collection("network_probes").update(probe.id, payload)
} else {
await pb.collection("network_probes").create(payload)
}
setOpen(false)
} catch (err: unknown) {
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 Probe` }}</Trans> : <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>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={(value) => setProtocol(value as ProbeProtocol)}>
<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>
{!isEditing && onOpenBulkAdd && (
<Button
type="button"
variant="outline"
onClick={() => onOpenBulkAdd(selectedSystemId)}
disabled={loading}
className="me-auto"
>
<ListIcon className="size-4 me-2" />
<Trans>Bulk Add</Trans>
</Button>
)}
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
{loading ? (
isEditing ? (
<Trans>Saving...</Trans>
) : (
<Trans>Creating...</Trans>
)
) : isEditing ? (
<Trans>Save {{ foo: t`Probe` }}</Trans>
) : (
<Trans>Add {{ foo: t`Probe` }}</Trans>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}