mirror of
https://github.com/henrygd/beszel.git
synced 2026-05-06 10:51:50 +02:00
updates
This commit is contained in:
@@ -12,7 +12,12 @@ 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 {
|
||||||
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.
|
// 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.
|
// 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 {
|
hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
|
||||||
systemID := e.Record.GetString("system")
|
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))
|
ID := generateProbeID(systemID, *probeConfigFromRecord(e.Record))
|
||||||
if ID != e.Record.Id {
|
if ID != e.Record.Id {
|
||||||
newRecord := copyProbeToNewRecord(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 {
|
func copyProbeToNewRecord(oldRecord *core.Record, newID string) *core.Record {
|
||||||
collection := oldRecord.Collection()
|
collection := oldRecord.Collection()
|
||||||
newRecord := core.NewRecord(collection)
|
newRecord := core.NewRecord(collection)
|
||||||
newRecord.Load(oldRecord.FieldsData())
|
newRecord.Id = newID
|
||||||
newRecord.Set("id", newID)
|
fields := []string{"system", "name", "target", "protocol", "port", "interval", "enabled"}
|
||||||
|
for _, field := range fields {
|
||||||
|
newRecord.Set(field, oldRecord.Get(field))
|
||||||
|
}
|
||||||
return newRecord
|
return newRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateProbeID(t *testing.T) {
|
func TestGenerateProbeID(t *testing.T) {
|
||||||
@@ -20,10 +22,21 @@ func TestGenerateProbeID(t *testing.T) {
|
|||||||
config: probe.Config{
|
config: probe.Config{
|
||||||
Protocol: "http",
|
Protocol: "http",
|
||||||
Target: "example.com",
|
Target: "example.com",
|
||||||
Port: 80,
|
Port: 0,
|
||||||
Interval: 60,
|
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",
|
name: "HTTP probe on example.com with different system ID",
|
||||||
@@ -34,7 +47,7 @@ func TestGenerateProbeID(t *testing.T) {
|
|||||||
Port: 80,
|
Port: 80,
|
||||||
Interval: 60,
|
Interval: 60,
|
||||||
},
|
},
|
||||||
expected: "be9e2707",
|
expected: "ab602ae7",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Same probe, different interval",
|
name: "Same probe, different interval",
|
||||||
@@ -45,7 +58,7 @@ func TestGenerateProbeID(t *testing.T) {
|
|||||||
Port: 80,
|
Port: 80,
|
||||||
Interval: 120,
|
Interval: 120,
|
||||||
},
|
},
|
||||||
expected: "be9e2707",
|
expected: "ab602ae7",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ICMP probe on 1.1.1.1",
|
name: "ICMP probe on 1.1.1.1",
|
||||||
@@ -56,7 +69,7 @@ func TestGenerateProbeID(t *testing.T) {
|
|||||||
Port: 0,
|
Port: 0,
|
||||||
Interval: 10,
|
Interval: 10,
|
||||||
},
|
},
|
||||||
expected: "49ec14fc",
|
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 +79,7 @@ func TestGenerateProbeID(t *testing.T) {
|
|||||||
Port: 0,
|
Port: 0,
|
||||||
Interval: 10,
|
Interval: 10,
|
||||||
},
|
},
|
||||||
expected: "84921aa3",
|
expected: "ddd6c81",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "TCP probe on example.com with port 443",
|
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"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CellContext, Column, ColumnDef } from "@tanstack/react-table"
|
import type { CellContext, Column, ColumnDef } from "@tanstack/react-table"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn, copyToClipboard, formatMicroseconds, hourWithSeconds } from "@/lib/utils"
|
import { cn, copyToClipboard, decimalString, formatMicroseconds, hourWithSeconds } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
@@ -236,7 +236,7 @@ export function getProbeColumns(
|
|||||||
return (
|
return (
|
||||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
||||||
{loss1h}%
|
{loss1h === 100 ? loss1h : decimalString(loss1h, loss1h >= 10 ? 1 : 2)}%
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ function NetworkProbeSheetContent({
|
|||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
<GlobeIcon className="size-3.5 text-muted-foreground" />
|
<GlobeIcon className="size-3.5 text-muted-foreground" />
|
||||||
{probe.target}
|
{probe.target}
|
||||||
{probe.port > 0 && (
|
{probe.protocol === "tcp" && probe.port > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
<EthernetPortIcon className="size-3.5 text-muted-foreground" />
|
<EthernetPortIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
|||||||
@@ -58,15 +58,19 @@ const NormalizedProbeValuesSchema = v.pipe(
|
|||||||
}),
|
}),
|
||||||
v.transform((input): NormalizedProbeValues => {
|
v.transform((input): NormalizedProbeValues => {
|
||||||
let { protocol, port } = input
|
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
|
port = 0
|
||||||
} else if ((protocol === "tcp" || protocol === "http") && !port) {
|
} else if (protocol === "tcp" && !port) {
|
||||||
port = 443
|
port = 443
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
// HTTP probes may be entered as bare hostnames, so normalize them to a
|
// HTTP probes may be entered as bare hostnames, so normalize them to a
|
||||||
// scheme-bearing URL before the payload is sent to PocketBase.
|
// 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,
|
protocol,
|
||||||
port,
|
port,
|
||||||
interval: input.interval,
|
interval: input.interval,
|
||||||
@@ -75,7 +79,7 @@ const NormalizedProbeValuesSchema = v.pipe(
|
|||||||
}),
|
}),
|
||||||
v.forward(
|
v.forward(
|
||||||
v.check((input) => {
|
v.check((input) => {
|
||||||
if (input.protocol === "icmp") {
|
if (input.protocol === "icmp" || input.protocol === "http") {
|
||||||
return input.port === 0
|
return input.port === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +99,31 @@ const BulkProbeSchema = v.object({
|
|||||||
name: v.optional(v.pipe(v.string(), v.trim())),
|
name: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
})
|
})
|
||||||
|
|
||||||
function normalizeHttpTarget(target: string, port: number) {
|
function normalizeHttpTarget(target: string, port = 0) {
|
||||||
if (/^https?:\/\//i.test(target)) {
|
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 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[]) {
|
function trimTrailingEmptyFields(fields: string[]) {
|
||||||
@@ -152,12 +175,13 @@ function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid probe entry"}`)
|
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({
|
return buildProbePayload({
|
||||||
system,
|
system,
|
||||||
target: parsed.output.target,
|
target: parsed.output.target,
|
||||||
protocol: (parsed.output.protocol?.toLowerCase() ||
|
protocol,
|
||||||
(/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as ProbeProtocol,
|
|
||||||
port: parsed.output.port ? Number(parsed.output.port) : 0,
|
port: parsed.output.port ? Number(parsed.output.port) : 0,
|
||||||
interval: parsed.output.interval || `${defaultInterval}`,
|
interval: parsed.output.interval || `${defaultInterval}`,
|
||||||
name: parsed.output.name || undefined,
|
name: parsed.output.name || undefined,
|
||||||
@@ -165,7 +189,7 @@ function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatBulkProbeLine(probe: BulkProbeLineSource) {
|
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}`
|
const interval = probe.interval === defaultInterval ? "" : `${probe.interval}`
|
||||||
return trimTrailingEmptyFields([probe.target, probe.protocol, port, interval, probe.name?.trim() || ""]).join(",")
|
return trimTrailingEmptyFields([probe.target, probe.protocol, port, interval, probe.name?.trim() || ""]).join(",")
|
||||||
}
|
}
|
||||||
@@ -402,9 +426,7 @@ function ProbeDialogContent({
|
|||||||
}) {
|
}) {
|
||||||
const [protocol, setProtocol] = useState<ProbeProtocol>(probe?.protocol ?? "icmp")
|
const [protocol, setProtocol] = useState<ProbeProtocol>(probe?.protocol ?? "icmp")
|
||||||
const [target, setTarget] = useState(probe?.target ?? "")
|
const [target, setTarget] = useState(probe?.target ?? "")
|
||||||
const [port, setPort] = useState(
|
const [port, setPort] = useState(probe?.protocol === "tcp" && probe.port ? String(probe.port) : "")
|
||||||
(probe?.protocol === "tcp" || probe?.protocol === "http") && probe.port ? String(probe.port) : ""
|
|
||||||
)
|
|
||||||
const [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? defaultInterval))
|
const [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? defaultInterval))
|
||||||
const [name, setName] = useState(probe?.name ?? "")
|
const [name, setName] = useState(probe?.name ?? "")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -423,7 +445,7 @@ function ProbeDialogContent({
|
|||||||
|
|
||||||
setProtocol(probe?.protocol ?? "icmp")
|
setProtocol(probe?.protocol ?? "icmp")
|
||||||
setTarget(probe?.target ?? "")
|
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))
|
setProbeInterval(String(probe?.interval ?? defaultInterval))
|
||||||
setName(probe?.name ?? "")
|
setName(probe?.name ?? "")
|
||||||
setSelectedSystemId(probe?.system ?? "")
|
setSelectedSystemId(probe?.system ?? "")
|
||||||
@@ -444,7 +466,7 @@ function ProbeDialogContent({
|
|||||||
system: selectedSystem,
|
system: selectedSystem,
|
||||||
target,
|
target,
|
||||||
protocol,
|
protocol,
|
||||||
port: protocol === "tcp" || protocol === "http" ? Number(port) : 0,
|
port: protocol === "tcp" ? Number(port) : 0,
|
||||||
interval: probeInterval,
|
interval: probeInterval,
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
@@ -500,7 +522,7 @@ function ProbeDialogContent({
|
|||||||
<Input
|
<Input
|
||||||
value={target}
|
value={target}
|
||||||
onChange={(e) => setTarget(e.target.value)}
|
onChange={(e) => 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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -520,7 +542,7 @@ function ProbeDialogContent({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{(protocol === "tcp" || protocol === "http") && (
|
{protocol === "tcp" && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
<Trans>Port</Trans>
|
<Trans>Port</Trans>
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ export function useNetworkProbeStats(props: UseNetworkProbeStatsProps) {
|
|||||||
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
|
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
|
||||||
const requestID = useRef(0)
|
const requestID = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!systemId) {
|
||||||
|
setProbeStats([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chartTime === "1m") {
|
||||||
|
setProbeStats(getCacheValue(systemId, "rt"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProbeStats(getCacheValue(systemId, chartTime))
|
||||||
|
}, [systemId, chartTime])
|
||||||
|
|
||||||
// fetch missing probe stats on load and when chart time changes
|
// fetch missing probe stats on load and when chart time changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!systemId || !chartTime || chartTime === "1m") {
|
if (!systemId || !chartTime || chartTime === "1m") {
|
||||||
@@ -148,7 +160,7 @@ export function useNetworkProbeStats(props: UseNetworkProbeStatsProps) {
|
|||||||
setProbeStats(newStats)
|
setProbeStats(newStats)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}, [chartTime])
|
}, [systemId, chartTime])
|
||||||
|
|
||||||
// Subscribe to new probe stats on non-1m chart times (1h, 12h, etc)
|
// Subscribe to new probe stats on non-1m chart times (1h, 12h, etc)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user