import { memo, useCallback, useEffect, useMemo, useRef, 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, getVisualStringWidth } from "@/lib/utils" import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { useToast } from "@/components/ui/use-toast" import { appendData } from "./chart-data" // 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, SystemRecord } from "@/types" import { type Row, type SortingState, flexRender, getCoreRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { getProbeColumns, type ProbeRow } from "./network-probes-columns" function probeKey(p: NetworkProbeRecord) { if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}` return `${p.protocol}:${p.target}` } export default function NetworkProbes({ system, chartData, grid, realtimeProbeStats, }: { system: SystemRecord chartData: ChartData grid: boolean realtimeProbeStats?: NetworkProbeStatsRecord[] }) { const systemId = system.id const [probes, setProbes] = useState([]) const [stats, setStats] = useState([]) const [latestResults, setLatestResults] = useState>({}) const chartTime = useStore($chartTime) const { toast } = useToast() const { t } = useLingui() const fetchProbes = useCallback(() => { pb.collection("network_probes") .getList(0, 2000, { fields: "id,name,target,protocol,port,interval,enabled,updated", filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, }) .then((res) => setProbes(res.items)) .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]) // Use realtime probe stats when in 1m mode useEffect(() => { if (chartTime !== "1m" || !realtimeProbeStats) { return } // Filter stats to only include currently active probes, preserving gap markers const data: NetworkProbeStatsRecord[] = realtimeProbeStats.map((r) => { if (!r.stats) { return r // preserve gap markers from appendData } const filtered: NetworkProbeStatsRecord["stats"] = {} for (const [key, val] of Object.entries(r.stats)) { if (activeProbeKeys.has(key)) { filtered[key] = val } } return { stats: filtered, created: r.created } }) setStats(data) // Use last non-gap entry for latest results for (let i = data.length - 1; i >= 0; i--) { if (data[i].stats) { const latest: Record = {} for (const [key, val] of Object.entries(data[i].stats)) { latest[key] = { avg: val?.[0], loss: val?.[3] } } setLatestResults(latest) break } } }, [chartTime, realtimeProbeStats, activeProbeKeys]) // Fetch probe stats based on chart time (skip in realtime mode) useEffect(() => { if (probes.length === 0) { setStats([]) setLatestResults({}) return } if (chartTime === "1m") { return } const controller = new AbortController() const { type: statsType = "1m", expectedInterval } = chartTimeData[chartTime] ?? {} console.log("Fetching probe stats", { systemId, statsType, expectedInterval }) pb.collection("network_probe_stats") .getList(0, 2000, { fields: "stats,created", filter: pb.filter("system={:system} && type={:type} && created <= {:created}", { system: systemId, type: statsType, created: new Date(Date.now() - 60 * 60 * 1000).toISOString(), }), sort: "-created", }) .then((raw) => { console.log("Fetched probe stats", { raw }) // Filter stats to only include currently active probes const mapped: NetworkProbeStatsRecord[] = raw.items.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() } }) // Apply gap detection — inserts null markers where data is missing const data = appendData([] as NetworkProbeStatsRecord[], mapped, expectedInterval) setStats(data) if (mapped.length > 0) { const last = mapped[mapped.length - 1].stats const latest: Record = {} for (const [key, val] of Object.entries(last)) { latest[key] = { avg: val?.[0], loss: val?.[3] } } setLatestResults(latest) } }) .catch((e) => { console.error("Error fetching probe stats", e) setStats([]) }) return () => controller.abort() }, [system, chartTime, probes, activeProbeKeys]) const deleteProbe = useCallback( async (id: string) => { try { await pb.collection("network_probes").delete(id) // fetchProbes() } catch (err: unknown) { toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message }) } }, [systemId, t] ) const dataPoints: DataPoint[] = useMemo(() => { const count = probes.length return probes.map((p, i) => { const key = probeKey(p) return { label: p.name || p.target, dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? null, color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`, } }) }, [probes]) const { longestName, longestTarget } = useMemo(() => { let longestName = 0 let longestTarget = 0 for (const p of probes) { longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target)) longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target)) } return { longestName, longestTarget } }, [probes]) const columns = useMemo( () => getProbeColumns(deleteProbe, longestName, longestTarget), [deleteProbe, longestName, longestTarget] ) const tableData: ProbeRow[] = useMemo( () => probes.map((p) => { const key = probeKey(p) const result = latestResults[key] return { ...p, key, latency: result?.avg, loss: result?.loss } }), [probes, latestResults] ) const [sorting, setSorting] = useState([{ id: "name", desc: false }]) const table = useReactTable({ data: tableData, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting, defaultColumn: { sortUndefined: "last", size: 100, minSize: 0, }, state: { sorting }, }) const rows = table.getRowModel().rows const visibleColumns = table.getVisibleLeafColumns() // if (probes.length === 0 && stats.length === 0) { // return ( // // //
//
// // Network Probes // // // ICMP/TCP/HTTP latency monitoring from this agent // //
// {/*
*/} // // {/*
*/} //
//
//
// ) // } // // console.log("Rendering NetworkProbes", { probes, stats }) return (
Network Probes ICMP/TCP/HTTP latency monitoring from this agent
{/* */}
{stats.length > 0 && ( `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`} contentFormatter={({ value }) => `${decimalString(value, 2)} ms`} legend /> )}
) } const ProbesTable = memo(function ProbesTable({ table, rows, colLength, }: { table: ReturnType> rows: Row[] colLength: number }) { const scrollRef = useRef(null) const virtualizer = useVirtualizer({ count: rows.length, estimateSize: () => 54, getScrollElement: () => scrollRef.current, overscan: 5, }) const virtualRows = virtualizer.getVirtualItems() const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) return (
2) && "min-h-50" )} ref={scrollRef} >
{rows.length ? ( virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] return }) ) : ( No results. )}
) }) function ProbesTableHead({ table }: { table: ReturnType> }) { return ( {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} ))} ) } const ProbesTableRow = memo(function ProbesTableRow({ row, virtualRow, }: { row: Row virtualRow: VirtualItem }) { return ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) })