mirror of
https://github.com/henrygd/beszel.git
synced 2026-05-06 10:51:50 +02:00
539 lines
16 KiB
Go
539 lines
16 KiB
Go
import { t } from "@lingui/core/macro"
|
|
import { Trans } from "@lingui/react/macro"
|
|
import {
|
|
type ColumnFiltersState,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getFilteredRowModel,
|
|
getSortedRowModel,
|
|
type Row,
|
|
type RowSelectionState,
|
|
type SortingState,
|
|
type Table as TableType,
|
|
useReactTable,
|
|
type VisibilityState,
|
|
} from "@tanstack/react-table"
|
|
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog"
|
|
import { Button, buttonVariants } from "@/components/ui/button"
|
|
import { memo, useCallback, useMemo, useRef, useState } from "react"
|
|
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
import { useToast } from "@/components/ui/use-toast"
|
|
import { isReadOnlyUser } from "@/lib/api"
|
|
import { pb } from "@/lib/api"
|
|
import { $allSystemsById, $chartTime, $direction } from "@/lib/stores"
|
|
import { cn, isVisuallyLonger, useBrowserStorage } from "@/lib/utils"
|
|
import type { NetworkProbeRecord } from "@/types"
|
|
import { AddProbeDialog, EditProbeDialog } from "./probe-dialog"
|
|
import { ArrowLeftRightIcon, EthernetPortIcon, GlobeIcon, ServerIcon, XIcon } from "lucide-react"
|
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
|
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
|
import { LossChart, AvgMinMaxResponseChart } from "@/components/routes/system/charts/probes-charts"
|
|
import { useNetworkProbeStats } from "@/lib/use-network-probes"
|
|
import { useStore } from "@nanostores/react"
|
|
import type { ChartData } from "@/types"
|
|
import { parseSemVer } from "@/lib/utils"
|
|
import { Separator } from "../ui/separator"
|
|
import { $router, Link } from "../router"
|
|
import { getPagePath } from "@nanostores/router"
|
|
|
|
export default function NetworkProbesTableNew({
|
|
systemId,
|
|
probes,
|
|
}: {
|
|
systemId?: string
|
|
probes: NetworkProbeRecord[]
|
|
}) {
|
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
|
`sort-np-${systemId ? 1 : 0}`,
|
|
[{ id: systemId ? "name" : "system", desc: false }],
|
|
sessionStorage
|
|
)
|
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
const [globalFilter, setGlobalFilter] = useState("")
|
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([])
|
|
const [editingProbe, setEditingProbe] = useState<NetworkProbeRecord>()
|
|
const { toast } = useToast()
|
|
const canManageProbes = !isReadOnlyUser()
|
|
|
|
const [longestName, longestTarget] = useMemo(() => {
|
|
let longestName = ""
|
|
let longestTarget = ""
|
|
for (const p of probes) {
|
|
const name = p.name || p.target
|
|
if (isVisuallyLonger(name, longestName)) {
|
|
longestName = name
|
|
}
|
|
if (isVisuallyLonger(p.target, longestTarget)) {
|
|
longestTarget = p.target
|
|
}
|
|
}
|
|
return [longestName, longestTarget]
|
|
}, [probes])
|
|
|
|
const runProbeBatch = useCallback(
|
|
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
|
|
let batch = pb.createBatch()
|
|
let inBatch = 0
|
|
for (const id of ids) {
|
|
enqueue(batch, id)
|
|
if (++inBatch >= 20) {
|
|
await batch.send()
|
|
batch = pb.createBatch()
|
|
inBatch = 0
|
|
}
|
|
}
|
|
if (inBatch) {
|
|
await batch.send()
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
|
|
const handleDeleteRequest = useCallback(
|
|
async (probesToDelete: NetworkProbeRecord[]) => {
|
|
if (!probesToDelete.length) {
|
|
return
|
|
}
|
|
|
|
const ids = probesToDelete.map((probe) => probe.id)
|
|
if (ids.length === 1) {
|
|
try {
|
|
await pb.collection("network_probes").delete(ids[0])
|
|
} catch (err: unknown) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: t`Error`,
|
|
description: (err as Error)?.message || t`Failed to delete probes.`,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
setPendingDeleteIds(ids)
|
|
setDeleteOpen(true)
|
|
},
|
|
[toast]
|
|
)
|
|
|
|
const handleBulkDelete = async () => {
|
|
setDeleteOpen(false)
|
|
if (!pendingDeleteIds.length) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await runProbeBatch(pendingDeleteIds, (batch, id) => batch.collection("network_probes").delete(id))
|
|
setPendingDeleteIds([])
|
|
setRowSelection({})
|
|
} catch (err: unknown) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: t`Error`,
|
|
description: (err as Error)?.message || t`Failed to delete probes.`,
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleSetEnabled = useCallback(
|
|
async (probesToUpdate: NetworkProbeRecord[], enabled: boolean) => {
|
|
if (!probesToUpdate.length) {
|
|
return
|
|
}
|
|
|
|
const pendingUpdates = probesToUpdate.filter((probe) => probe.enabled !== enabled)
|
|
if (!pendingUpdates.length) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (pendingUpdates.length === 1) {
|
|
await pb.collection("network_probes").update(pendingUpdates[0].id, { enabled })
|
|
return
|
|
}
|
|
await runProbeBatch(
|
|
pendingUpdates.map((probe) => probe.id),
|
|
(batch, id) => batch.collection("network_probes").update(id, { enabled })
|
|
)
|
|
if (probesToUpdate.length > 1) {
|
|
setRowSelection({})
|
|
}
|
|
} catch (err: unknown) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: t`Error`,
|
|
description: (err as Error)?.message || t`Failed to update probes.`,
|
|
})
|
|
}
|
|
},
|
|
[runProbeBatch, toast]
|
|
)
|
|
|
|
const columns = useMemo(() => {
|
|
let columns = getProbeColumns(longestName, longestTarget, {
|
|
onEdit: setEditingProbe,
|
|
onDelete: handleDeleteRequest,
|
|
onSetEnabled: handleSetEnabled,
|
|
})
|
|
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
|
columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions")
|
|
return columns
|
|
}, [canManageProbes, handleDeleteRequest, handleSetEnabled, longestName, systemId, longestTarget])
|
|
|
|
const table = useReactTable({
|
|
data: probes,
|
|
columns,
|
|
getRowId: (row) => row.id,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
onSortingChange: setSorting,
|
|
onColumnFiltersChange: setColumnFilters,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
onRowSelectionChange: setRowSelection,
|
|
defaultColumn: {
|
|
sortUndefined: "last",
|
|
size: 900,
|
|
minSize: 0,
|
|
},
|
|
state: {
|
|
sorting,
|
|
columnFilters,
|
|
columnVisibility,
|
|
rowSelection,
|
|
globalFilter,
|
|
},
|
|
onGlobalFilterChange: setGlobalFilter,
|
|
globalFilterFn: (row, _columnId, filterValue) => {
|
|
const probe = row.original
|
|
const systemName = $allSystemsById.get()[probe.system]?.name ?? ""
|
|
const searchString = `${probe.name}${probe.target}${probe.protocol}${systemName}`.toLocaleLowerCase()
|
|
return (filterValue as string)
|
|
.toLowerCase()
|
|
.split(" ")
|
|
.every((term) => searchString.includes(term))
|
|
},
|
|
})
|
|
|
|
const rows = table.getRowModel().rows
|
|
const visibleColumns = table.getVisibleLeafColumns()
|
|
|
|
return (
|
|
<Card className="@container 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>
|
|
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
|
<Trans>Response time monitoring from agents.</Trans>
|
|
</div>
|
|
</div>
|
|
<div className="md:ms-auto flex items-center gap-2">
|
|
{probes.length > 0 && (
|
|
<div className="relative">
|
|
<Input
|
|
placeholder={t`Filter...`}
|
|
value={globalFilter}
|
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
className="ms-auto px-4 w-full max-w-full md:w-50"
|
|
/>
|
|
{globalFilter && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={t`Clear`}
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
|
onClick={() => setGlobalFilter("")}
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{canManageProbes ? <AddProbeDialog systemId={systemId} probes={probes} /> : null}
|
|
{canManageProbes ? (
|
|
<EditProbeDialog
|
|
systemId={systemId}
|
|
probe={editingProbe}
|
|
open={!!editingProbe}
|
|
setOpen={(open) => {
|
|
if (!open) {
|
|
setEditingProbe(undefined)
|
|
}
|
|
}}
|
|
/>
|
|
) : null}
|
|
<AlertDialog
|
|
open={deleteOpen}
|
|
onOpenChange={(open) => {
|
|
setDeleteOpen(open)
|
|
if (!open) {
|
|
setPendingDeleteIds([])
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
<Trans>Are you sure?</Trans>
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
<Trans>This will permanently delete all selected records from the database.</Trans>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
<Trans>Cancel</Trans>
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
onClick={handleBulkDelete}
|
|
>
|
|
<Trans>Continue</Trans>
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<div className="rounded-md">
|
|
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} rowSelection={rowSelection} />
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const NetworkProbesTable = memo(function NetworkProbeTable({
|
|
table,
|
|
rows,
|
|
colLength,
|
|
rowSelection: _rowSelection,
|
|
}: {
|
|
table: TableType<NetworkProbeRecord>
|
|
rows: Row<NetworkProbeRecord>[]
|
|
colLength: number
|
|
rowSelection: RowSelectionState
|
|
}) {
|
|
// The virtualizer will need a reference to the scrollable container element
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const [sheetOpen, setSheetOpen] = useState(false)
|
|
const [activeProbeId, setActiveProbeId] = useState<string | null>(null)
|
|
const activeProbe = activeProbeId ? table.options.data.find((probe) => probe.id === activeProbeId) : undefined
|
|
const openSheet = useCallback((probe: NetworkProbeRecord) => {
|
|
setActiveProbeId(probe.id)
|
|
setSheetOpen(true)
|
|
}, [])
|
|
|
|
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)] max-w-full relative overflow-auto border rounded-md",
|
|
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
|
(!rows.length || rows.length > 2) && "min-h-50"
|
|
)}
|
|
ref={scrollRef}
|
|
>
|
|
{/* add header height to table size */}
|
|
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
|
<table className="text-sm w-full h-full text-nowrap">
|
|
<NetworkProbeTableHead table={table} />
|
|
<TableBody>
|
|
{rows.length ? (
|
|
virtualRows.map((virtualRow) => {
|
|
const row = rows[virtualRow.index]
|
|
return (
|
|
<NetworkProbeTableRow
|
|
key={row.id}
|
|
row={row}
|
|
virtualRow={virtualRow}
|
|
isSelected={row.getIsSelected()}
|
|
openSheet={openSheet}
|
|
/>
|
|
)
|
|
})
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
|
<Trans>No results.</Trans>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</table>
|
|
</div>
|
|
<NetworkProbeSheet
|
|
open={sheetOpen}
|
|
onOpenChange={(nextOpen) => {
|
|
setSheetOpen(nextOpen)
|
|
}}
|
|
probe={activeProbe}
|
|
/>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord> }) {
|
|
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) => {
|
|
return (
|
|
<TableHead className="px-2" key={header.id}>
|
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
</TableHead>
|
|
)
|
|
})}
|
|
</tr>
|
|
))}
|
|
</TableHeader>
|
|
)
|
|
}
|
|
|
|
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
|
row,
|
|
virtualRow,
|
|
isSelected,
|
|
openSheet,
|
|
}: {
|
|
row: Row<NetworkProbeRecord>
|
|
virtualRow: VirtualItem
|
|
isSelected: boolean
|
|
openSheet: (probe: NetworkProbeRecord) => void
|
|
}) {
|
|
return (
|
|
<TableRow
|
|
data-state={isSelected && "selected"}
|
|
className="cursor-pointer transition-opacity"
|
|
onClick={() => openSheet(row.original)}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell
|
|
key={cell.id}
|
|
className="py-0"
|
|
style={{
|
|
width: `${cell.column.getSize()}px`,
|
|
height: virtualRow.size,
|
|
}}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
)
|
|
})
|
|
|
|
function NetworkProbeSheet({
|
|
open,
|
|
onOpenChange,
|
|
probe,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
probe?: NetworkProbeRecord
|
|
}) {
|
|
if (!probe) {
|
|
return null
|
|
}
|
|
|
|
return <NetworkProbeSheetContent key={probe.system} open={open} onOpenChange={onOpenChange} probe={probe} />
|
|
}
|
|
|
|
function NetworkProbeSheetContent({
|
|
open,
|
|
onOpenChange,
|
|
probe,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
probe: NetworkProbeRecord
|
|
}) {
|
|
const chartTime = useStore($chartTime)
|
|
const direction = useStore($direction)
|
|
const system = useStore($allSystemsById)[probe.system]
|
|
|
|
const probeStats = useNetworkProbeStats({ systemId: probe.system, chartTime })
|
|
|
|
const chartData = useMemo<ChartData>(
|
|
() => ({
|
|
agentVersion: parseSemVer(system?.info?.v),
|
|
orientation: direction === "rtl" ? "right" : "left",
|
|
chartTime,
|
|
}),
|
|
[probeStats]
|
|
)
|
|
const hasProbeStats = probeStats.some((record) => record.stats?.[probe.id] != null)
|
|
const probeLabel = probe.name || probe.target
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="w-full sm:max-w-220 overflow-auto p-4 sm:p-6">
|
|
<SheetHeader className="mb-0 border-b p-0 pb-4">
|
|
<SheetTitle>{probeLabel}</SheetTitle>
|
|
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
<ServerIcon className="size-3.5 text-muted-foreground" />
|
|
<Link className="hover:underline" href={getPagePath($router, "system", { id: system?.id ?? "" })}>
|
|
{system?.name ?? ""}
|
|
</Link>
|
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground" />
|
|
{probe.protocol.toUpperCase()}
|
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
<GlobeIcon className="size-3.5 text-muted-foreground" />
|
|
{probe.target}
|
|
{probe.port > 0 && (
|
|
<>
|
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
<EthernetPortIcon className="size-3.5 text-muted-foreground" />
|
|
<span>{probe.port}</span>
|
|
</>
|
|
)}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="grid gap-4">
|
|
<ChartTimeSelect className="bg-card" agentVersion={chartData.agentVersion} />
|
|
<AvgMinMaxResponseChart probeStats={probeStats} probe={probe} chartData={chartData} empty={!hasProbeStats} />
|
|
<LossChart
|
|
probeStats={probeStats}
|
|
grid={false}
|
|
probes={[probe]}
|
|
chartData={chartData}
|
|
empty={!hasProbeStats}
|
|
showFilter={false}
|
|
/>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|