From e154123511a150023a267e8039c58ea22947c6c7 Mon Sep 17 00:00:00 2001 From: henrygd Date: Thu, 23 Apr 2026 21:34:56 -0400 Subject: [PATCH] updates --- internal/hub/probes.go | 43 +- internal/hub/probes_test.go | 10 +- internal/hub/systems/system.go | 54 ++- .../network-probes-columns.tsx | 25 +- .../network-probes-table.tsx | 17 +- .../network-probes-table/probe-dialog.tsx | 369 +++++++++++------- 6 files changed, 321 insertions(+), 197 deletions(-) diff --git a/internal/hub/probes.go b/internal/hub/probes.go index 08ff701c..dd35a5d8 100644 --- a/internal/hub/probes.go +++ b/internal/hub/probes.go @@ -1,8 +1,6 @@ package hub import ( - "strconv" - "github.com/henrygd/beszel/internal/entities/probe" "github.com/henrygd/beszel/internal/hub/systems" "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. func generateProbeID(systemId string, config probe.Config) string { - intervalStr := strconv.FormatUint(uint64(config.Interval), 10) - portStr := strconv.FormatUint(uint64(config.Port), 10) - return systems.MakeStableHashId(systemId, config.Protocol, config.Target, portStr, intervalStr) + return systems.MakeStableHashId(systemId, config.Target, config.Protocol) } // 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 - hub.OnRecordCreateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error { + hub.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error { err := e.Next() if err != nil { return err @@ -47,10 +43,24 @@ func bindNetworkProbesEvents(hub *Hub) { 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) } - 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 { + 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() if err != nil { return err @@ -67,15 +77,11 @@ func bindNetworkProbesEvents(hub *Hub) { }) // sync probe to agent on delete - hub.OnRecordDeleteRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error { - err := e.Next() - if err != nil { - return err - } + hub.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error { 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) } - return nil + return e.Next() }) } @@ -99,6 +105,17 @@ func setProbeResultFields(record *core.Record, result probe.Result) { 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. func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) (*probe.Result, error) { systemID := record.GetString("system") diff --git a/internal/hub/probes_test.go b/internal/hub/probes_test.go index c67bf4f5..f71511e9 100644 --- a/internal/hub/probes_test.go +++ b/internal/hub/probes_test.go @@ -23,7 +23,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 80, Interval: 60, }, - expected: "d5f27931", + expected: "a20a5827", }, { name: "HTTP probe on example.com with different system ID", @@ -34,7 +34,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 80, Interval: 60, }, - expected: "6f8b17f1", + expected: "ab602ae7", }, { name: "Same probe, different interval", @@ -45,7 +45,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 80, Interval: 120, }, - expected: "6d4baf8", + expected: "ab602ae7", }, { name: "ICMP probe on 1.1.1.1", @@ -56,7 +56,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 0, Interval: 10, }, - expected: "80b5836b", + expected: "6d13a4a4", }, { name: "ICMP probe on 1.1.1.1 with different system ID", systemID: "sys4567", @@ -66,7 +66,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 0, Interval: 10, }, - expected: "a6652680", + expected: "ddd6c81", }, } diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index ca471720..f09da46a 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "hash/fnv" - "log/slog" "math/rand" "net" "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. // Otherwise we can do a more efficient direct update via SQL 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) }) @@ -335,35 +333,10 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst if !realtimeActive { db = app.DB() 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) } - // 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 for id, values := range data { 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 } diff --git a/internal/site/src/components/network-probes-table/network-probes-columns.tsx b/internal/site/src/components/network-probes-table/network-probes-columns.tsx index 587aab4b..c44d203a 100644 --- a/internal/site/src/components/network-probes-table/network-probes-columns.tsx +++ b/internal/site/src/components/network-probes-table/network-probes-columns.tsx @@ -12,10 +12,17 @@ import { ClockIcon, NetworkIcon, RefreshCwIcon, + PenBoxIcon, } from "lucide-react" import { t } from "@lingui/core/macro" 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 { pb } from "@/lib/api" import { toast } from "../ui/use-toast" @@ -36,7 +43,11 @@ async function deleteProbe(id: string) { } } -export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef[] { +export function getProbeColumns( + longestName = 0, + longestTarget = 0, + onEdit?: (probe: NetworkProbeRecord) => void +): ColumnDef[] { return [ { id: "name", @@ -177,6 +188,16 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef event.stopPropagation()}> + { + event.stopPropagation() + onEdit?.(row.original) + }} + > + + Edit + + { event.stopPropagation() diff --git a/internal/site/src/components/network-probes-table/network-probes-table.tsx b/internal/site/src/components/network-probes-table/network-probes-table.tsx index 453321d8..91bd0eff 100644 --- a/internal/site/src/components/network-probes-table/network-probes-table.tsx +++ b/internal/site/src/components/network-probes-table/network-probes-table.tsx @@ -40,7 +40,7 @@ import { $allSystemsById } from "@/lib/stores" import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils" import { Trash2Icon } from "lucide-react" import type { NetworkProbeRecord } from "@/types" -import { AddProbeDialog } from "./probe-dialog" +import { AddProbeDialog, EditProbeDialog } from "./probe-dialog" export default function NetworkProbesTableNew({ systemId, @@ -59,6 +59,7 @@ export default function NetworkProbesTableNew({ const [rowSelection, setRowSelection] = useState({}) const [globalFilter, setGlobalFilter] = useState("") const [deleteOpen, setDeleteOpen] = useState(false) + const [editingProbe, setEditingProbe] = useState() const { toast } = useToast() const canManageProbes = !isReadOnlyUser() @@ -74,7 +75,7 @@ export default function NetworkProbesTableNew({ // Filter columns based on whether systemId is provided const columns = useMemo(() => { - let columns = getProbeColumns(longestName, longestTarget) + let columns = getProbeColumns(longestName, longestTarget, setEditingProbe) columns = systemId ? columns.filter((col) => col.id !== "system") : columns columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions") if (!canManageProbes) { @@ -233,6 +234,18 @@ export default function NetworkProbesTableNew({ /> )} {canManageProbes ? : null} + {canManageProbes ? ( + { + if (!open) { + setEditingProbe(undefined) + } + }} + /> + ) : null} diff --git a/internal/site/src/components/network-probes-table/probe-dialog.tsx b/internal/site/src/components/network-probes-table/probe-dialog.tsx index 6d7c1537..bbeab5ab 100644 --- a/internal/site/src/components/network-probes-table/probe-dialog.tsx +++ b/internal/site/src/components/network-probes-table/probe-dialog.tsx @@ -20,6 +20,7 @@ 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" @@ -59,6 +60,8 @@ function buildProbePayload(values: ProbeValues) { payload.name = trimmedName } else if (targetName !== values.target) { payload.name = targetName + } else { + payload.name = "" } return payload } @@ -105,36 +108,19 @@ function parseBulkProbeLine(line: string, lineNumber: number, system: string) { export function AddProbeDialog({ systemId }: { systemId?: string }) { const [open, setOpen] = useState(false) const [bulkOpen, setBulkOpen] = useState(false) - const [protocol, setProtocol] = useState("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() - const targetName = target.replace(/^https?:\/\//, "") - - const resetForm = () => { - setProtocol("icmp") - setTarget("") - setPort("") - setProbeInterval("30") - setName("") - setSelectedSystemId("") - } + const systems = useStore($systems) const resetBulkForm = () => { setBulkInput("") setBulkSelectedSystemId("") } - const openBulkAdd = () => { + const openBulkAdd = (selectedSystemId?: string) => { if (!systemId && selectedSystemId) { setBulkSelectedSystemId(selectedSystemId) } @@ -147,29 +133,6 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) { 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) { e.preventDefault() setBulkLoading(true) @@ -227,119 +190,31 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) { - + openBulkAdd(systemId)}> Bulk Add - - - - - Add {{ foo: t`Network Probe` }} - - - Configure response monitoring from this agent. - - -
- {!systemId && ( -
- - -
- )} -
- - setTarget(e.target.value)} - placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"} - required - /> -
-
- - - -
- {protocol === "tcp" && ( -
- - setPort(e.target.value)} - placeholder="443" - min={1} - max={65535} - required - /> -
- )} -
- - setProbeInterval(e.target.value)} - min={1} - max={3600} - required - /> -
-
- - setName(e.target.value)} - placeholder={targetName || t`e.g. Cloudflare DNS`} - /> -
- - - -
-
+ { + setOpen(nextOpen) + }} + > + {open && } - + { + setBulkOpen(nextOpen) + if (!nextOpen) { + resetBulkForm() + } + }} + > @@ -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 ( + + {open && } + + ) +} + +function ProbeDialogContent({ + setOpen, + systemId, + probe, + onOpenBulkAdd, +}: { + setOpen: (open: boolean) => void + systemId?: string + probe?: NetworkProbeRecord + onOpenBulkAdd?: (selectedSystemId?: string) => void +}) { + const [protocol, setProtocol] = useState(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 ( + + + + {isEditing ? Edit {{ foo: t`Network Probe` }} : Add {{ foo: t`Network Probe` }}} + + + Configure response monitoring from this agent. + + +
+ {!systemId && ( +
+ + +
+ )} +
+ + setTarget(e.target.value)} + placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"} + required + /> +
+
+ + + +
+ {protocol === "tcp" && ( +
+ + setPort(e.target.value)} + placeholder="443" + min={1} + max={65535} + required + /> +
+ )} +
+ + setProbeInterval(e.target.value)} + min={1} + max={3600} + required + /> +
+
+ + setName(e.target.value)} + placeholder={targetName || t`e.g. Cloudflare DNS`} + /> +
+ + {!isEditing && onOpenBulkAdd && ( + + )} + + +
+
+ ) +}