diff --git a/internal/site/src/components/charts/line-chart.tsx b/internal/site/src/components/charts/line-chart.tsx index b4d2d9e1..8861b667 100644 --- a/internal/site/src/components/charts/line-chart.tsx +++ b/internal/site/src/components/charts/line-chart.tsx @@ -41,6 +41,7 @@ export default function LineChartDefault({ filter, truncate = false, chartProps, + connectNulls, }: { chartData: ChartData // biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData) @@ -62,6 +63,7 @@ export default function LineChartDefault({ filter?: string truncate?: boolean chartProps?: Omit, "data" | "margin"> + connectNulls?: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) @@ -105,6 +107,7 @@ export default function LineChartDefault({ // stackId={dataPoint.stackId} order={dataPoint.order || i} // activeDot={dataPoint.activeDot ?? true} + connectNulls={connectNulls} /> ) }) diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 36bda035..4f65ccb9 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -1,4 +1,4 @@ -import { memo, useState, Suspense, lazy } from "react" +import { memo, useState } from "react" import { Trans } from "@lingui/react/macro" import { compareSemVer, parseSemVer } from "@/lib/utils" import type { GPUData } from "@/types" @@ -11,8 +11,7 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts" import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts" -import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables" -const NetworkProbes = lazy(() => import("./system/network-probes")) +import { LazyContainersTable, LazyNetworkProbesTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables" import { LoadAverageChart } from "./system/charts/load-average-chart" import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react" import { GpuIcon } from "../ui/icons" @@ -148,9 +147,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { {hasSystemd && } - - - + ) } @@ -198,9 +195,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { {pageBottomExtraMargin > 0 &&
} - - - + diff --git a/internal/site/src/components/routes/system/lazy-tables.tsx b/internal/site/src/components/routes/system/lazy-tables.tsx index f487369c..976bd537 100644 --- a/internal/site/src/components/routes/system/lazy-tables.tsx +++ b/internal/site/src/components/routes/system/lazy-tables.tsx @@ -34,3 +34,26 @@ export function LazySystemdTable({ systemId }: { systemId: string }) { ) } + +const NetworkProbesTable = lazy(() => import("@/components/routes/system/network-probes")) + +export function LazyNetworkProbesTable({ + system, + chartData, + grid, + probeStats, +}: { + system: any + chartData: any + grid: any + probeStats: any +}) { + const { isIntersecting, ref } = useIntersectionObserver() + return ( +
+ {isIntersecting && ( + + )} +
+ ) +} diff --git a/internal/site/src/components/routes/system/network-probes-columns.tsx b/internal/site/src/components/routes/system/network-probes-columns.tsx new file mode 100644 index 00000000..fc15b9d3 --- /dev/null +++ b/internal/site/src/components/routes/system/network-probes-columns.tsx @@ -0,0 +1,171 @@ +import type { Column, ColumnDef } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { cn, decimalString } from "@/lib/utils" +import { + GlobeIcon, + TagIcon, + TimerIcon, + ActivityIcon, + WifiOffIcon, + Trash2Icon, + ArrowLeftRightIcon, + MoreHorizontalIcon, +} from "lucide-react" +import { t } from "@lingui/core/macro" +import type { NetworkProbeRecord } from "@/types" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Trans } from "@lingui/react/macro" + +export interface ProbeRow extends NetworkProbeRecord { + key: string + latency?: number + loss?: number +} + +const protocolColors: Record = { + icmp: "bg-blue-500/15 text-blue-400", + tcp: "bg-purple-500/15 text-purple-400", + http: "bg-green-500/15 text-green-400", +} + +export function getProbeColumns( + deleteProbe: (id: string) => void, + longestName = 0, + longestTarget = 0 +): ColumnDef[] { + return [ + { + id: "name", + sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target), + accessorFn: (record) => record.name || record.target, + header: ({ column }) => , + cell: ({ getValue }) => ( +
+ {getValue() as string} +
+ ), + }, + { + id: "target", + sortingFn: (a, b) => a.original.target.localeCompare(b.original.target), + accessorFn: (record) => record.target, + header: ({ column }) => , + cell: ({ getValue }) => ( +
+ {getValue() as string} +
+ ), + }, + { + id: "protocol", + accessorFn: (record) => record.protocol, + header: ({ column }) => , + cell: ({ getValue }) => { + const protocol = getValue() as string + return ( + + {protocol} + + ) + }, + }, + { + id: "interval", + accessorFn: (record) => record.interval, + header: ({ column }) => , + cell: ({ getValue }) => {getValue() as number}s, + }, + { + id: "latency", + accessorFn: (record) => record.latency, + invertSorting: true, + header: ({ column }) => , + cell: ({ row }) => { + const val = row.original.latency + if (val === undefined) { + return - + } + return ( + + 100 ? "bg-yellow-500" : "bg-green-500")} /> + {decimalString(val, val < 100 ? 2 : 1).toLocaleString()} ms + + ) + }, + }, + { + id: "loss", + accessorFn: (record) => record.loss, + invertSorting: true, + header: ({ column }) => , + cell: ({ row }) => { + const val = row.original.loss + if (val === undefined) { + return - + } + return ( + + 0 ? "bg-yellow-500" : "bg-green-500")} /> + {val}% + + ) + }, + }, + { + id: "actions", + enableSorting: false, + header: () => null, + cell: ({ row }) => ( +
+ + + + + event.stopPropagation()}> + { + event.stopPropagation() + deleteProbe(row.original.id) + }} + > + + Delete + + + +
+ ), + }, + ] +} + +function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) { + const isSorted = column.getIsSorted() + return ( + + ) +} diff --git a/internal/site/src/components/routes/system/network-probes.tsx b/internal/site/src/components/routes/system/network-probes.tsx index 80923600..236d99f7 100644 --- a/internal/site/src/components/routes/system/network-probes.tsx +++ b/internal/site/src/components/routes/system/network-probes.tsx @@ -1,12 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from "react" +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 } from "@/lib/utils" +import { chartTimeData, cn, toFixedFloat, decimalString, getVisualStringWidth } 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 { appendData } from "./chart-data" import { AddProbeDialog } from "./probe-dialog" @@ -14,6 +12,17 @@ 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}` @@ -131,17 +140,20 @@ export default function NetworkProbes({ return () => controller.abort() }, [system, 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 deleteProbe = useCallback( + 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 }) + } + }, + [fetchProbes, toast, t] + ) const dataPoints: DataPoint[] = useMemo(() => { const count = probes.length @@ -150,53 +162,83 @@ export default function NetworkProbes({ return { label: p.name || p.target, dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.avg ?? null, - color: - count <= 5 - ? i + 1 - : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`, + color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`, } }) }, [probes]) - if (probes.length === 0 && stats.length === 0) { - return ( - - -
-
- - Network Probes - - - ICMP/TCP/HTTP latency monitoring from this agent - -
- -
-
-
- ) - } - - const protocolBadge = (protocol: string) => { - const colors: Record = { - icmp: "bg-blue-500/15 text-blue-400", - tcp: "bg-purple-500/15 text-purple-400", - http: "bg-green-500/15 text-green-400", + 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 ( - - {protocol} - - ) - } + 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 + // + //
+ // {/*
*/} + // + // {/*
*/} + //
+ //
+ //
+ // ) + // } return (
- - -
-
+ + +
+
Network Probes @@ -208,83 +250,17 @@ export default function NetworkProbes({
-
- - - - - - - - - - - - - - {probes.map((p) => { - const key = probeKey(p) - const result = latestResults[key] - return ( - - - - - - - - - - ) - })} - -
- Name - - Target - - Protocol - - Interval - - Latency - - Loss -
{p.name || p.target}{p.target}{protocolBadge(p.protocol)}{p.interval}s - {result ? ( - 100 ? "text-yellow-400" : "text-green-400"}> - {toFixedFloat(result.avg, 1)} ms - - ) : ( - - - )} - - {result ? ( - 0 ? "text-red-400" : "text-green-400"}> - {toFixedFloat(result.loss, 1)}% - - ) : ( - - - )} - - -
-
+ {stats.length > 0 && ( - + `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`} contentFormatter={({ value }) => `${decimalString(value, 2)} ms`} legend @@ -294,3 +270,90 @@ export default function NetworkProbes({
) } + +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())} + + ))} + + ) +})