mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 04:01:50 +02:00
Displays probe list with protocol badges, latency/loss stats, and delete functionality. Includes a latency line chart using ChartCard with data sourced from the network-probe-stats API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
239 lines
7.4 KiB
Go
239 lines
7.4 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"
|
|
|
|
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])
|
|
|
|
// Fetch probe stats based on chart time
|
|
useEffect(() => {
|
|
if (probes.length === 0) return
|
|
const controller = new AbortController()
|
|
const statsType = chartTimeData[chartTime]?.type ?? "1m"
|
|
|
|
pb.send<{ stats: any; created: string }[]>("/api/beszel/network-probe-stats", {
|
|
query: { system: systemId, type: statsType },
|
|
signal: controller.signal,
|
|
})
|
|
.then((raw) => {
|
|
const data: NetworkProbeStatsRecord[] = raw.map((r) => ({
|
|
stats: r.stats,
|
|
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.length])
|
|
|
|
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 probeKey = (p: NetworkProbeRecord) => {
|
|
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
|
return `${p.protocol}:${p.target}`
|
|
}
|
|
|
|
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 || "-"}</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>
|
|
)
|
|
}
|