Files
beszel-ipv6/internal/site/src/components/routes/system/network-probes.tsx
xiaomiku01 fab5e8a656 fix(ui): filter deleted probes from latency chart stats
Stats records in the DB contain historical data for all probes including
deleted ones. Now filters stats by active probe keys and clears state
when all probes are removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00

252 lines
7.9 KiB
Go

import { useCallback, useEffect, useMemo, useState } from "react"
import { Trans, useLingui } from "@lingui/react/macro"
import { pb } from "@/lib/api"
import { useStore } from "@nanostores/react"
import { $chartTime } from "@/lib/stores"
import { chartTimeData, cn, toFixedFloat, decimalString } from "@/lib/utils"
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Trash2Icon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { AddProbeDialog } from "./probe-dialog"
import { ChartCard } from "./chart-card"
import LineChartDefault, { type DataPoint } from "@/components/charts/line-chart"
import { pinnedAxisDomain } from "@/components/ui/chart"
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
function probeKey(p: NetworkProbeRecord) {
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
return `${p.protocol}:${p.target}`
}
export default function NetworkProbes({
systemId,
chartData,
grid,
}: {
systemId: string
chartData: ChartData
grid: boolean
}) {
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
const [stats, setStats] = useState<NetworkProbeStatsRecord[]>([])
const [latestResults, setLatestResults] = useState<Record<string, { avg: number; loss: number }>>({})
const chartTime = useStore($chartTime)
const { toast } = useToast()
const { t } = useLingui()
const fetchProbes = useCallback(() => {
pb.send<NetworkProbeRecord[]>("/api/beszel/network-probes", {
query: { system: systemId },
})
.then(setProbes)
.catch(() => setProbes([]))
}, [systemId])
useEffect(() => {
fetchProbes()
}, [fetchProbes])
// Build set of current probe keys to filter out deleted probes from stats
const activeProbeKeys = useMemo(() => new Set(probes.map(probeKey)), [probes])
// Fetch probe stats based on chart time
useEffect(() => {
if (probes.length === 0) {
setStats([])
setLatestResults({})
return
}
const controller = new AbortController()
const statsType = chartTimeData[chartTime]?.type ?? "1m"
pb.send<{ stats: NetworkProbeStatsRecord["stats"]; created: string }[]>("/api/beszel/network-probe-stats", {
query: { system: systemId, type: statsType },
signal: controller.signal,
})
.then((raw) => {
// Filter stats to only include currently active probes
const data: NetworkProbeStatsRecord[] = raw.map((r) => {
const filtered: NetworkProbeStatsRecord["stats"] = {}
for (const [key, val] of Object.entries(r.stats)) {
if (activeProbeKeys.has(key)) {
filtered[key] = val
}
}
return { stats: filtered, created: new Date(r.created).getTime() }
})
setStats(data)
if (data.length > 0) {
const last = data[data.length - 1].stats
const latest: Record<string, { avg: number; loss: number }> = {}
for (const [key, val] of Object.entries(last)) {
latest[key] = { avg: val.avg, loss: val.loss }
}
setLatestResults(latest)
}
})
.catch(() => setStats([]))
return () => controller.abort()
}, [systemId, chartTime, probes, activeProbeKeys])
const deleteProbe = async (id: string) => {
try {
await pb.send("/api/beszel/network-probes", {
method: "DELETE",
query: { id },
})
fetchProbes()
} catch (err: any) {
toast({ variant: "destructive", title: t`Error`, description: err?.message })
}
}
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
return probes.map((p, i) => {
const key = probeKey(p)
return {
label: p.name || p.target,
dataKey: (record: NetworkProbeStatsRecord) => record.stats[key]?.avg ?? null,
color: (i % 10) + 1,
}
})
}, [probes])
if (probes.length === 0 && stats.length === 0) {
return (
<Card className="px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0">
<div className="flex items-center justify-between">
<div>
<CardTitle>
<Trans>Network Probes</Trans>
</CardTitle>
<CardDescription className="mt-1.5">
<Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
</CardDescription>
</div>
<AddProbeDialog systemId={systemId} onCreated={fetchProbes} />
</div>
</CardHeader>
</Card>
)
}
const protocolBadge = (protocol: string) => {
const colors: Record<string, string> = {
icmp: "bg-blue-500/15 text-blue-400",
tcp: "bg-purple-500/15 text-purple-400",
http: "bg-green-500/15 text-green-400",
}
return (
<span className={cn("px-2 py-0.5 rounded text-xs font-medium uppercase", colors[protocol] ?? "")}>
{protocol}
</span>
)
}
return (
<div className="grid gap-4">
<Card className="px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle>
<Trans>Network Probes</Trans>
</CardTitle>
<CardDescription className="mt-1.5">
<Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
</CardDescription>
</div>
<AddProbeDialog systemId={systemId} onCreated={fetchProbes} />
</div>
</CardHeader>
<div className="overflow-x-auto -mx-3 sm:-mx-6">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-muted-foreground">
<th className="text-left font-medium px-3 sm:px-6 py-2">
<Trans>Name</Trans>
</th>
<th className="text-left font-medium px-3 py-2">
<Trans>Target</Trans>
</th>
<th className="text-left font-medium px-3 py-2">
<Trans>Protocol</Trans>
</th>
<th className="text-left font-medium px-3 py-2">
<Trans>Interval</Trans>
</th>
<th className="text-left font-medium px-3 py-2">
<Trans>Latency</Trans>
</th>
<th className="text-left font-medium px-3 py-2">
<Trans>Loss</Trans>
</th>
<th className="text-right font-medium px-3 sm:px-6 py-2"></th>
</tr>
</thead>
<tbody>
{probes.map((p) => {
const key = probeKey(p)
const result = latestResults[key]
return (
<tr key={p.id} className="border-b last:border-0">
<td className="px-3 sm:px-6 py-2.5 text-muted-foreground">{p.name || p.target}</td>
<td className="px-3 py-2.5 font-mono text-xs">{p.target}</td>
<td className="px-3 py-2.5">{protocolBadge(p.protocol)}</td>
<td className="px-3 py-2.5">{p.interval}s</td>
<td className="px-3 py-2.5">
{result ? (
<span className={result.avg > 100 ? "text-yellow-400" : "text-green-400"}>
{toFixedFloat(result.avg, 1)} ms
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-3 py-2.5">
{result ? (
<span className={result.loss > 0 ? "text-red-400" : "text-green-400"}>
{toFixedFloat(result.loss, 1)}%
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-3 sm:px-6 py-2.5 text-right">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => deleteProbe(p.id)}>
<Trash2Icon className="h-3.5 w-3.5 text-destructive" />
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
{stats.length > 0 && (
<ChartCard
title={t`Latency`}
description={t`Average round-trip time (ms)`}
grid={grid}
>
<LineChartDefault
chartData={chartData}
customData={stats}
dataPoints={dataPoints}
domain={pinnedAxisDomain()}
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
contentFormatter={({ value }) => `${decimalString(value, 2)} ms`}
legend
/>
</ChartCard>
)}
</div>
)
}