mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 04:01:50 +02:00
updates
This commit is contained in:
@@ -41,6 +41,7 @@ export default function LineChartDefault({
|
|||||||
filter,
|
filter,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
chartProps,
|
chartProps,
|
||||||
|
connectNulls,
|
||||||
}: {
|
}: {
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||||
@@ -62,6 +63,7 @@ export default function LineChartDefault({
|
|||||||
filter?: string
|
filter?: string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
||||||
|
connectNulls?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
@@ -105,6 +107,7 @@ export default function LineChartDefault({
|
|||||||
// stackId={dataPoint.stackId}
|
// stackId={dataPoint.stackId}
|
||||||
order={dataPoint.order || i}
|
order={dataPoint.order || i}
|
||||||
// activeDot={dataPoint.activeDot ?? true}
|
// activeDot={dataPoint.activeDot ?? true}
|
||||||
|
connectNulls={connectNulls}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useState, Suspense, lazy } from "react"
|
import { memo, useState } from "react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { compareSemVer, parseSemVer } from "@/lib/utils"
|
import { compareSemVer, parseSemVer } from "@/lib/utils"
|
||||||
import type { GPUData } from "@/types"
|
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 { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
||||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
|
import { LazyContainersTable, LazyNetworkProbesTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
|
||||||
const NetworkProbes = lazy(() => import("./system/network-probes"))
|
|
||||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||||
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
||||||
import { GpuIcon } from "../ui/icons"
|
import { GpuIcon } from "../ui/icons"
|
||||||
@@ -148,9 +147,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
|
|
||||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||||
|
|
||||||
<Suspense>
|
<LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} />
|
||||||
<NetworkProbes system={system} chartData={chartData} grid={grid} realtimeProbeStats={probeStats} />
|
|
||||||
</Suspense>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -198,9 +195,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||||
</div>
|
</div>
|
||||||
<Suspense>
|
<LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} />
|
||||||
<NetworkProbes system={system} chartData={chartData} grid={grid} realtimeProbeStats={probeStats} />
|
|
||||||
</Suspense>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
||||||
|
|||||||
@@ -34,3 +34,26 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||||
|
{isIntersecting && (
|
||||||
|
<NetworkProbesTable system={system} chartData={chartData} grid={grid} realtimeProbeStats={probeStats} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<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",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProbeColumns(
|
||||||
|
deleteProbe: (id: string) => void,
|
||||||
|
longestName = 0,
|
||||||
|
longestTarget = 0
|
||||||
|
): ColumnDef<ProbeRow>[] {
|
||||||
|
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 }) => <HeaderButton column={column} name={t`Name`} Icon={TagIcon} />,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
|
||||||
|
{getValue() as string}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "target",
|
||||||
|
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
|
||||||
|
accessorFn: (record) => record.target,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<div className="ms-1.5 tabular-nums block truncate max-w-44" style={{ width: `${longestTarget / 1.05}ch` }}>
|
||||||
|
{getValue() as string}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "protocol",
|
||||||
|
accessorFn: (record) => record.protocol,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const protocol = getValue() as string
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ms-1.5 px-2 py-0.5 rounded text-xs font-medium uppercase", protocolColors[protocol] ?? "")}
|
||||||
|
>
|
||||||
|
{protocol}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "interval",
|
||||||
|
accessorFn: (record) => record.interval,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={TimerIcon} />,
|
||||||
|
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "latency",
|
||||||
|
accessorFn: (record) => record.latency,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Latency`} Icon={ActivityIcon} />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = row.original.latency
|
||||||
|
if (val === undefined) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||||
|
<span className={cn("shrink-0 size-2 rounded-full", val > 100 ? "bg-yellow-500" : "bg-green-500")} />
|
||||||
|
{decimalString(val, val < 100 ? 2 : 1).toLocaleString()} ms
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loss",
|
||||||
|
accessorFn: (record) => record.loss,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Loss`} Icon={WifiOffIcon} />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = row.original.loss
|
||||||
|
if (val === undefined) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||||
|
<span className={cn("shrink-0 size-2 rounded-full", val > 0 ? "bg-yellow-500" : "bg-green-500")} />
|
||||||
|
{val}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-10"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
deleteProbe(row.original.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderButton({ column, name, Icon }: { column: Column<ProbeRow>; name: string; Icon: React.ElementType }) {
|
||||||
|
const isSorted = column.getIsSorted()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||||
|
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||||
|
)}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="size-4" />}
|
||||||
|
{name}
|
||||||
|
{/* <ArrowUpDownIcon className="size-4" /> */}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $chartTime } from "@/lib/stores"
|
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 { 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 { useToast } from "@/components/ui/use-toast"
|
||||||
import { appendData } from "./chart-data"
|
import { appendData } from "./chart-data"
|
||||||
import { AddProbeDialog } from "./probe-dialog"
|
import { AddProbeDialog } from "./probe-dialog"
|
||||||
@@ -14,6 +12,17 @@ import { ChartCard } from "./chart-card"
|
|||||||
import LineChartDefault, { type DataPoint } from "@/components/charts/line-chart"
|
import LineChartDefault, { type DataPoint } from "@/components/charts/line-chart"
|
||||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||||
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord, SystemRecord } from "@/types"
|
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) {
|
function probeKey(p: NetworkProbeRecord) {
|
||||||
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
||||||
@@ -131,17 +140,20 @@ export default function NetworkProbes({
|
|||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [system, chartTime, probes, activeProbeKeys])
|
}, [system, chartTime, probes, activeProbeKeys])
|
||||||
|
|
||||||
const deleteProbe = async (id: string) => {
|
const deleteProbe = useCallback(
|
||||||
try {
|
async (id: string) => {
|
||||||
await pb.send("/api/beszel/network-probes", {
|
try {
|
||||||
method: "DELETE",
|
await pb.send("/api/beszel/network-probes", {
|
||||||
query: { id },
|
method: "DELETE",
|
||||||
})
|
query: { id },
|
||||||
fetchProbes()
|
})
|
||||||
} catch (err: any) {
|
fetchProbes()
|
||||||
toast({ variant: "destructive", title: t`Error`, description: err?.message })
|
} catch (err: any) {
|
||||||
}
|
toast({ variant: "destructive", title: t`Error`, description: err?.message })
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[fetchProbes, toast, t]
|
||||||
|
)
|
||||||
|
|
||||||
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
|
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
|
||||||
const count = probes.length
|
const count = probes.length
|
||||||
@@ -150,53 +162,83 @@ export default function NetworkProbes({
|
|||||||
return {
|
return {
|
||||||
label: p.name || p.target,
|
label: p.name || p.target,
|
||||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.avg ?? null,
|
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.avg ?? null,
|
||||||
color:
|
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
||||||
count <= 5
|
|
||||||
? i + 1
|
|
||||||
: `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [probes])
|
}, [probes])
|
||||||
|
|
||||||
if (probes.length === 0 && stats.length === 0) {
|
const { longestName, longestTarget } = useMemo(() => {
|
||||||
return (
|
let longestName = 0
|
||||||
<Card className="px-3 py-5 sm:py-6 sm:px-6">
|
let longestTarget = 0
|
||||||
<CardHeader className="p-0">
|
for (const p of probes) {
|
||||||
<div className="flex items-center justify-between">
|
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
|
||||||
<div>
|
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
|
||||||
<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 (
|
return { longestName, longestTarget }
|
||||||
<span className={cn("px-2 py-0.5 rounded text-xs font-medium uppercase", colors[protocol] ?? "")}>
|
}, [probes])
|
||||||
{protocol}
|
|
||||||
</span>
|
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<SortingState>([{ 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 (
|
||||||
|
// <Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
|
// <CardHeader className="p-0 mb-3 sm:mb-4">
|
||||||
|
// <div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||||
|
// <div className="px-2 sm:px-1">
|
||||||
|
// <CardTitle className="mb-2">
|
||||||
|
// <Trans>Network Probes</Trans>
|
||||||
|
// </CardTitle>
|
||||||
|
// <CardDescription>
|
||||||
|
// <Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
|
||||||
|
// </CardDescription>
|
||||||
|
// </div>
|
||||||
|
// {/* <div className="relative ms-auto w-full max-w-full md:w-64"> */}
|
||||||
|
// <AddProbeDialog systemId={systemId} onCreated={fetchProbes} />
|
||||||
|
// {/* </div> */}
|
||||||
|
// </div>
|
||||||
|
// </CardHeader>
|
||||||
|
// </Card>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<Card className="px-3 py-5 sm:py-6 sm:px-6">
|
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
<CardHeader className="p-0 mb-4">
|
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end md:justify-between">
|
||||||
<div>
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
<Trans>Network Probes</Trans>
|
<Trans>Network Probes</Trans>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -208,83 +250,17 @@ export default function NetworkProbes({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<div className="overflow-x-auto -mx-3 sm:-mx-6">
|
<ProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||||
<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>
|
</Card>
|
||||||
|
|
||||||
{stats.length > 0 && (
|
{stats.length > 0 && (
|
||||||
<ChartCard
|
<ChartCard title={t`Latency`} description={t`Average round-trip time (ms)`} grid={grid}>
|
||||||
title={t`Latency`}
|
|
||||||
description={t`Average round-trip time (ms)`}
|
|
||||||
grid={grid}
|
|
||||||
>
|
|
||||||
<LineChartDefault
|
<LineChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
customData={stats}
|
customData={stats}
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
domain={pinnedAxisDomain()}
|
domain={pinnedAxisDomain()}
|
||||||
|
connectNulls
|
||||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
|
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
|
||||||
contentFormatter={({ value }) => `${decimalString(value, 2)} ms`}
|
contentFormatter={({ value }) => `${decimalString(value, 2)} ms`}
|
||||||
legend
|
legend
|
||||||
@@ -294,3 +270,90 @@ export default function NetworkProbes({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ProbesTable = memo(function ProbesTable({
|
||||||
|
table,
|
||||||
|
rows,
|
||||||
|
colLength,
|
||||||
|
}: {
|
||||||
|
table: ReturnType<typeof useReactTable<ProbeRow>>
|
||||||
|
rows: Row<ProbeRow>[]
|
||||||
|
colLength: number
|
||||||
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-min max-h-[calc(100dvh-17rem)] w-full relative overflow-auto rounded-md border",
|
||||||
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
|
)}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||||
|
<table className="w-full text-sm text-nowrap">
|
||||||
|
<ProbesTableHead table={table} />
|
||||||
|
<TableBody>
|
||||||
|
{rows.length ? (
|
||||||
|
virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index]
|
||||||
|
return <ProbesTableRow key={row.id} row={row} virtualRow={virtualRow} />
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
|
<Trans>No results.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProbesTableHead({ table }: { table: ReturnType<typeof useReactTable<ProbeRow>> }) {
|
||||||
|
return (
|
||||||
|
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead className="px-2" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProbesTableRow = memo(function ProbesTableRow({
|
||||||
|
row,
|
||||||
|
virtualRow,
|
||||||
|
}: {
|
||||||
|
row: Row<ProbeRow>
|
||||||
|
virtualRow: VirtualItem
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="py-0" style={{ height: virtualRow.size }}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user