mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 04:01:50 +02:00
feat(ui): add network probes table and latency chart section
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>
This commit is contained in:
238
internal/site/src/components/routes/system/network-probes.tsx
Normal file
238
internal/site/src/components/routes/system/network-probes.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user