From 3534552d371f0f5f345538a68aa5e310166a3e75 Mon Sep 17 00:00:00 2001 From: henrygd Date: Wed, 29 Apr 2026 20:06:51 -0400 Subject: [PATCH] updates --- internal/hub/probes.go | 18 ++++- internal/hub/probes_test.go | 66 +++++++++++++++++-- .../network-probes-columns.tsx | 4 +- .../network-probes-table.tsx | 2 +- .../network-probes-table/probe-dialog.tsx | 56 +++++++++++----- internal/site/src/lib/use-network-probes.ts | 14 +++- 6 files changed, 130 insertions(+), 30 deletions(-) diff --git a/internal/hub/probes.go b/internal/hub/probes.go index 483a1550..32f9b1e0 100644 --- a/internal/hub/probes.go +++ b/internal/hub/probes.go @@ -12,7 +12,12 @@ 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 { - return systems.MakeStableHashId(systemId, config.Target, config.Protocol, strconv.FormatUint(uint64(config.Port), 10)) + args := []string{systemId, config.Target, config.Protocol} + // only use port for TCP probes, since for other protocols it's not relevant as standalone value + if config.Protocol == "tcp" { + args = append(args, strconv.FormatUint(uint64(config.Port), 10)) + } + return systems.MakeStableHashId(args...) } // bindNetworkProbesEvents keeps probe records and agent probe state in sync. @@ -48,6 +53,10 @@ func bindNetworkProbesEvents(hub *Hub) { // record with the new ID and delete the old one. Otherwise, just update the existing probe on the agent. hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error { systemID := e.Record.GetString("system") + // only tcp uses port - set other protocols port to zero + if e.Record.GetString("protocol") != "tcp" { + e.Record.Set("port", 0) + } ID := generateProbeID(systemID, *probeConfigFromRecord(e.Record)) if ID != e.Record.Id { newRecord := copyProbeToNewRecord(e.Record, ID) @@ -111,8 +120,11 @@ func setProbeResultFields(record *core.Record, result probe.Result) { func copyProbeToNewRecord(oldRecord *core.Record, newID string) *core.Record { collection := oldRecord.Collection() newRecord := core.NewRecord(collection) - newRecord.Load(oldRecord.FieldsData()) - newRecord.Set("id", newID) + newRecord.Id = newID + fields := []string{"system", "name", "target", "protocol", "port", "interval", "enabled"} + for _, field := range fields { + newRecord.Set(field, oldRecord.Get(field)) + } return newRecord } diff --git a/internal/hub/probes_test.go b/internal/hub/probes_test.go index ebb55847..97badc0b 100644 --- a/internal/hub/probes_test.go +++ b/internal/hub/probes_test.go @@ -4,7 +4,9 @@ import ( "testing" "github.com/henrygd/beszel/internal/entities/probe" + "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGenerateProbeID(t *testing.T) { @@ -20,10 +22,21 @@ func TestGenerateProbeID(t *testing.T) { config: probe.Config{ Protocol: "http", Target: "example.com", - Port: 80, + Port: 0, Interval: 60, }, - expected: "de7b3647", + expected: "a20a5827", + }, + { + name: "HTTP probe on example.com with different port", + systemID: "sys123", + config: probe.Config{ + Protocol: "http", + Target: "example.com", + Port: 8080, + Interval: 60, + }, + expected: "a20a5827", }, { name: "HTTP probe on example.com with different system ID", @@ -34,7 +47,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 80, Interval: 60, }, - expected: "be9e2707", + expected: "ab602ae7", }, { name: "Same probe, different interval", @@ -45,7 +58,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 80, Interval: 120, }, - expected: "be9e2707", + expected: "ab602ae7", }, { name: "ICMP probe on 1.1.1.1", @@ -56,7 +69,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 0, Interval: 10, }, - expected: "49ec14fc", + expected: "6d13a4a4", }, { name: "ICMP probe on 1.1.1.1 with different system ID", systemID: "sys4567", @@ -66,7 +79,7 @@ func TestGenerateProbeID(t *testing.T) { Port: 0, Interval: 10, }, - expected: "84921aa3", + expected: "ddd6c81", }, { name: "TCP probe on example.com with port 443", @@ -99,3 +112,44 @@ func TestGenerateProbeID(t *testing.T) { }) } } + +func TestCopyProbeToNewRecordDropsResultFields(t *testing.T) { + hub, testApp, err := createTestHub(t) + require.NoError(t, err) + defer cleanupTestHub(hub, testApp) + + collection, err := hub.FindCachedCollectionByNameOrId("network_probes") + require.NoError(t, err) + + oldRecord := core.NewRecord(collection) + oldRecord.Load(map[string]any{ + "system": "sys123", + "name": "Example", + "target": "https://example.com", + "protocol": "http", + "port": 443, + "interval": 60, + "enabled": true, + "res": 1200, + "resAvg1h": 1300, + "resMin1h": 900, + "resMax1h": 1600, + "loss1h": 5, + "updated": "2026-04-29 12:00:00.000Z", + }) + + newRecord := copyProbeToNewRecord(oldRecord, "next12345") + + assert.Equal(t, "next12345", newRecord.Id) + assert.Equal(t, "Example", newRecord.GetString("name")) + assert.Equal(t, "https://example.com", newRecord.GetString("target")) + assert.Equal(t, "http", newRecord.GetString("protocol")) + assert.Equal(t, 443, newRecord.GetInt("port")) + assert.True(t, newRecord.GetBool("enabled")) + assert.Zero(t, newRecord.GetFloat("res")) + assert.Zero(t, newRecord.GetFloat("resAvg1h")) + assert.Zero(t, newRecord.GetFloat("resMin1h")) + assert.Zero(t, newRecord.GetFloat("resMax1h")) + assert.Zero(t, newRecord.GetFloat("loss1h")) + assert.Equal(t, "", newRecord.GetString("updated")) +} 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 6a276a71..e30dd83c 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 @@ -1,6 +1,6 @@ import type { CellContext, Column, ColumnDef } from "@tanstack/react-table" import { Button } from "@/components/ui/button" -import { cn, copyToClipboard, formatMicroseconds, hourWithSeconds } from "@/lib/utils" +import { cn, copyToClipboard, decimalString, formatMicroseconds, hourWithSeconds } from "@/lib/utils" import { GlobeIcon, TimerIcon, @@ -236,7 +236,7 @@ export function getProbeColumns( return ( - {loss1h}% + {loss1h === 100 ? loss1h : decimalString(loss1h, loss1h >= 10 ? 1 : 2)}% ) }, 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 629997b5..09e484e1 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 @@ -511,7 +511,7 @@ function NetworkProbeSheetContent({ {probe.target} - {probe.port > 0 && ( + {probe.protocol === "tcp" && probe.port > 0 && ( <> 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 0ed2c2d6..584616bd 100644 --- a/internal/site/src/components/network-probes-table/probe-dialog.tsx +++ b/internal/site/src/components/network-probes-table/probe-dialog.tsx @@ -58,15 +58,19 @@ const NormalizedProbeValuesSchema = v.pipe( }), v.transform((input): NormalizedProbeValues => { let { protocol, port } = input - if (protocol === "icmp") { + let httpTarget = input.target + if (protocol === "icmp" || protocol === "http") { + if (protocol === "http") { + httpTarget = normalizeHttpTarget(input.target, port) + } port = 0 - } else if ((protocol === "tcp" || protocol === "http") && !port) { + } else if (protocol === "tcp" && !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, + target: protocol === "http" ? httpTarget : input.target, protocol, port, interval: input.interval, @@ -75,7 +79,7 @@ const NormalizedProbeValuesSchema = v.pipe( }), v.forward( v.check((input) => { - if (input.protocol === "icmp") { + if (input.protocol === "icmp" || input.protocol === "http") { return input.port === 0 } @@ -95,12 +99,31 @@ const BulkProbeSchema = v.object({ name: v.optional(v.pipe(v.string(), v.trim())), }) -function normalizeHttpTarget(target: string, port: number) { - if (/^https?:\/\//i.test(target)) { +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 } - return `${port === 443 ? "https" : "http"}://${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[]) { @@ -152,12 +175,13 @@ function parseBulkProbeLine(line: string, lineNumber: number, system: string) { if (!parsed.success) { throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid probe entry"}`) } + const protocol = (parsed.output.protocol?.toLowerCase() || + (/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as ProbeProtocol return buildProbePayload({ system, target: parsed.output.target, - protocol: (parsed.output.protocol?.toLowerCase() || - (/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as ProbeProtocol, + protocol, port: parsed.output.port ? Number(parsed.output.port) : 0, interval: parsed.output.interval || `${defaultInterval}`, name: parsed.output.name || undefined, @@ -165,7 +189,7 @@ function parseBulkProbeLine(line: string, lineNumber: number, system: string) { } export function formatBulkProbeLine(probe: BulkProbeLineSource) { - const port = probe.protocol === "icmp" || probe.port === 443 ? "" : `${probe.port}` + const port = probe.protocol !== "tcp" || probe.port === 443 ? "" : `${probe.port}` const interval = probe.interval === defaultInterval ? "" : `${probe.interval}` return trimTrailingEmptyFields([probe.target, probe.protocol, port, interval, probe.name?.trim() || ""]).join(",") } @@ -402,9 +426,7 @@ function ProbeDialogContent({ }) { const [protocol, setProtocol] = useState(probe?.protocol ?? "icmp") const [target, setTarget] = useState(probe?.target ?? "") - const [port, setPort] = useState( - (probe?.protocol === "tcp" || probe?.protocol === "http") && probe.port ? String(probe.port) : "" - ) + const [port, setPort] = useState(probe?.protocol === "tcp" && probe.port ? String(probe.port) : "") const [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? defaultInterval)) const [name, setName] = useState(probe?.name ?? "") const [loading, setLoading] = useState(false) @@ -423,7 +445,7 @@ function ProbeDialogContent({ setProtocol(probe?.protocol ?? "icmp") setTarget(probe?.target ?? "") - setPort((probe?.protocol === "tcp" || probe?.protocol === "http") && probe.port ? String(probe.port) : "") + setPort(probe?.protocol === "tcp" && probe.port ? String(probe.port) : "") setProbeInterval(String(probe?.interval ?? defaultInterval)) setName(probe?.name ?? "") setSelectedSystemId(probe?.system ?? "") @@ -444,7 +466,7 @@ function ProbeDialogContent({ system: selectedSystem, target, protocol, - port: protocol === "tcp" || protocol === "http" ? Number(port) : 0, + port: protocol === "tcp" ? Number(port) : 0, interval: probeInterval, name, }, @@ -500,7 +522,7 @@ function ProbeDialogContent({ setTarget(e.target.value)} - placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"} + placeholder={protocol === "http" ? "http://localhost:8090" : "1.1.1.1"} required /> @@ -520,7 +542,7 @@ function ProbeDialogContent({ - {(protocol === "tcp" || protocol === "http") && ( + {protocol === "tcp" && (