This commit is contained in:
henrygd
2026-04-23 02:28:03 -04:00
parent 0d440e5fb9
commit 9f7c1b22bb
4 changed files with 468 additions and 121 deletions

View File

@@ -157,6 +157,7 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
{
id: "actions",
enableSorting: false,
enableHiding: false,
header: () => null,
size: 40,
cell: ({ row }) => (

View File

@@ -2,25 +2,43 @@ import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
type ColumnDef,
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,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { memo, useMemo, useRef, useState } from "react"
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
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 } from "@/lib/stores"
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
import { Trash2Icon } from "lucide-react"
import type { NetworkProbeRecord } from "@/types"
import { AddProbeDialog } from "./probe-dialog"
@@ -38,7 +56,11 @@ export default function NetworkProbesTableNew({
)
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 { toast } = useToast()
const canManageProbes = !isReadOnlyUser()
const { longestName, longestTarget } = useMemo(() => {
let longestName = 0
@@ -54,19 +76,79 @@ export default function NetworkProbesTableNew({
const columns = useMemo(() => {
let columns = getProbeColumns(longestName, longestTarget)
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
columns = isReadOnlyUser() ? columns.filter((col) => col.id !== "actions") : columns
return columns
}, [systemId, longestName, longestTarget])
columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions")
if (!canManageProbes) {
return columns
}
const selectionColumn: ColumnDef<NetworkProbeRecord> = {
id: "select",
header: ({ table }) => (
<Checkbox
className="ms-2"
checked={table.getIsAllRowsSelected() || (table.getIsSomeRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label={t`Select all`}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t`Select row`}
/>
),
enableSorting: false,
enableHiding: false,
size: 44,
}
return [selectionColumn, ...columns]
}, [systemId, longestName, longestTarget, canManageProbes])
const handleBulkDelete = async () => {
setDeleteOpen(false)
const selectedIds = Object.keys(rowSelection)
if (!selectedIds.length) {
return
}
try {
let batch = pb.createBatch()
let inBatch = 0
for (const id of selectedIds) {
batch.collection("network_probes").delete(id)
inBatch++
if (inBatch >= 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
if (inBatch) {
await batch.send()
}
table.resetRowSelection()
} catch (err: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (err as Error)?.message || t`Failed to delete probes.`,
})
}
}
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,
@@ -76,6 +158,7 @@ export default function NetworkProbesTableNew({
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
@@ -106,20 +189,55 @@ export default function NetworkProbesTableNew({
</div>
</div>
<div className="md:ms-auto flex items-center gap-2">
{canManageProbes && table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-1 items-center gap-4 z-50 backdrop-blur-md shrink-0 md:static md:p-0 md:w-auto md:gap-3">
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="h-9 shrink-0">
<Trash2Icon className="size-4 shrink-0" />
<span className="ms-1">
<Trans>Delete</Trans>
</span>
</Button>
</AlertDialogTrigger>
<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>
)}
{probes.length > 0 && (
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-64"
className="ms-auto px-4 w-full max-w-full md:w-50"
/>
)}
{!isReadOnlyUser() ? <AddProbeDialog systemId={systemId} /> : null}
{canManageProbes ? <AddProbeDialog systemId={systemId} /> : null}
</div>
</div>
</CardHeader>
<div className="rounded-md">
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} rowSelection={rowSelection} />
</div>
</Card>
)
@@ -129,10 +247,12 @@ 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)
@@ -165,7 +285,14 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <NetworkProbeTableRow key={row.id} row={row} virtualRow={virtualRow} />
return (
<NetworkProbeTableRow
key={row.id}
row={row}
virtualRow={virtualRow}
isSelected={row.getIsSelected()}
/>
)
})
) : (
<TableRow>
@@ -202,12 +329,14 @@ function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord>
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
row,
virtualRow,
isSelected,
}: {
row: Row<NetworkProbeRecord>
virtualRow: VirtualItem
isSelected: boolean
}) {
return (
<TableRow data-state={row.getIsSelected() && "selected"} className="transition-opacity">
<TableRow data-state={isSelected && "selected"} className="transition-opacity">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}

View File

@@ -9,17 +9,30 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PlusIcon } from "lucide-react"
import { Textarea } from "@/components/ui/textarea"
import { ChevronDownIcon, ListIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores"
import * as v from "valibot"
type ProbeProtocol = "icmp" | "tcp" | "http"
type ProbeValues = {
system: string
target: string
protocol: ProbeProtocol
port: number
interval: string
name?: string
}
const Schema = v.object({
system: v.string(),
target: v.string(),
@@ -30,15 +43,78 @@ const Schema = v.object({
name: v.optional(v.string()),
})
function buildProbePayload(values: ProbeValues) {
const normalizedPort = (values.protocol === "tcp" || values.protocol === "http") && !values.port ? 443 : values.port
const payload = v.parse(Schema, {
system: values.system,
target: values.target,
protocol: values.protocol,
port: normalizedPort,
interval: values.interval,
enabled: true,
})
const trimmedName = values.name?.trim()
const targetName = values.target.replace(/^https?:\/\//i, "")
if (trimmedName) {
payload.name = trimmedName
} else if (targetName !== values.target) {
payload.name = targetName
}
return payload
}
function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",")
const target = rawTarget.trim()
if (!target) {
throw new Error(`Line ${lineNumber}: target is required`)
}
const inferredProtocol: ProbeProtocol = /^https?:\/\//i.test(target) ? "http" : "icmp"
const protocolValue = rawProtocol.trim().toLowerCase() || inferredProtocol
if (protocolValue !== "icmp" && protocolValue !== "tcp" && protocolValue !== "http") {
throw new Error(`Line ${lineNumber}: protocol must be icmp, tcp, or http`)
}
const portValue = rawPort.trim()
if (protocolValue === "tcp") {
const port = portValue ? Number(portValue) : 443
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Line ${lineNumber}: TCP entries require a port between 1 and 65535`)
}
return buildProbePayload({
system,
target,
protocol: "tcp",
port,
interval: rawInterval.trim() || "30",
name: rawName.join(",").trim() || undefined,
})
}
return buildProbePayload({
system,
target,
protocol: protocolValue,
port: 0,
interval: rawInterval.trim() || "30",
name: rawName.join(",").trim() || undefined,
})
}
export function AddProbeDialog({ systemId }: { systemId?: string }) {
const [open, setOpen] = useState(false)
const [bulkOpen, setBulkOpen] = useState(false)
const [protocol, setProtocol] = useState<string>("icmp")
const [target, setTarget] = useState("")
const [port, setPort] = useState("")
const [probeInterval, setProbeInterval] = useState("30")
const [name, setName] = useState("")
const [loading, setLoading] = useState(false)
const [bulkInput, setBulkInput] = useState("")
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState("")
const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("")
const systems = useStore($systems)
const { toast } = useToast()
const { t } = useLingui()
@@ -53,24 +129,37 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
setSelectedSystemId("")
}
const handleSubmit = async (e: React.FormEvent) => {
const resetBulkForm = () => {
setBulkInput("")
setBulkSelectedSystemId("")
}
const openBulkAdd = () => {
if (!systemId && selectedSystemId) {
setBulkSelectedSystemId(selectedSystemId)
}
setOpen(false)
setBulkOpen(true)
}
const openAdd = () => {
setBulkOpen(false)
setOpen(true)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const payload = v.parse(Schema, {
const payload = buildProbePayload({
system: systemId ?? selectedSystemId,
target,
protocol,
protocol: protocol as ProbeProtocol,
port: protocol === "tcp" ? Number(port) : 0,
interval: probeInterval,
enabled: true,
name,
})
if (name) {
payload.name = name
} else if (targetName !== target) {
payload.name = targetName
}
await pb.collection("network_probes").create(payload)
resetForm()
setOpen(false)
@@ -81,116 +170,244 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
}
}
async function handleBulkSubmit(e: React.FormEvent) {
e.preventDefault()
setBulkLoading(true)
let closedForSubmit = false
try {
const system = systemId ?? bulkSelectedSystemId
const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim())
if (!rawLines.length) {
throw new Error("Enter at least one probe.")
}
const payloads = rawLines.map((line, index) => parseBulkProbeLine(line, index + 1, system))
setBulkOpen(false)
closedForSubmit = true
let batch = pb.createBatch()
let inBatch = 0
for (const payload of payloads) {
batch.collection("network_probes").create(payload)
inBatch++
if (inBatch > 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
if (inBatch) {
await batch.send()
}
resetBulkForm()
toast({ title: t`Probes created`, description: `${payloads.length} probe(s) added.` })
} catch (err: unknown) {
if (closedForSubmit) {
setBulkOpen(true)
}
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
} finally {
setBulkLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<PlusIcon className="size-4 me-1" />
<>
<div className="flex gap-0 rounded-lg">
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow">
{/* <PlusIcon className="size-4 me-1" /> */}
<Trans>Add {{ foo: t`Probe` }}</Trans>
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure response monitoring from this agent.</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
{!systemId && (
<div className="w-px h-full bg-muted"></div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="px-2 rounded-s-none border-s-0" aria-label={t`More probe actions`}>
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={openBulkAdd}>
<ListIcon className="size-4 me-2" />
<Trans>Bulk Add</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure response monitoring from this agent.</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
{!systemId && (
<div className="grid gap-2">
<Label>
<Trans>System</Trans>
</Label>
<Select value={selectedSystemId} onValueChange={setSelectedSystemId} required>
<SelectTrigger>
<SelectValue placeholder={t`Select a system`} />
</SelectTrigger>
<SelectContent>
{systems.map((sys) => (
<SelectItem key={sys.id} value={sys.id}>
{sys.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>System</Trans>
</Label>
<Select value={selectedSystemId} onValueChange={setSelectedSystemId} required>
<SelectTrigger>
<SelectValue placeholder={t`Select a system`} />
</SelectTrigger>
<SelectContent>
{systems.map((sys) => (
<SelectItem key={sys.id} value={sys.id}>
{sys.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Target</Trans>
</Label>
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Protocol</Trans>
</Label>
<Select value={protocol} onValueChange={setProtocol}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
</div>
{protocol === "tcp" && (
<div className="grid gap-2">
<Label>
<Trans>Port</Trans>
<Trans>Target</Trans>
</Label>
<Input
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="443"
min={1}
max={65535}
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"}
required
/>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Interval (seconds)</Trans>
</Label>
<Input
type="number"
value={probeInterval}
onChange={(e) => setProbeInterval(e.target.value)}
min={1}
max={3600}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Name (optional)</Trans>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={targetName || t`e.g. Cloudflare DNS`}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="grid gap-2">
<Label>
<Trans>Protocol</Trans>
</Label>
<Select value={protocol} onValueChange={setProtocol}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
</div>
{protocol === "tcp" && (
<div className="grid gap-2">
<Label>
<Trans>Port</Trans>
</Label>
<Input
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="443"
min={1}
max={65535}
required
/>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Interval (seconds)</Trans>
</Label>
<Input
type="number"
value={probeInterval}
onChange={(e) => setProbeInterval(e.target.value)}
min={1}
max={3600}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Name (optional)</Trans>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={targetName || t`e.g. Cloudflare DNS`}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Sheet open={bulkOpen} onOpenChange={setBulkOpen}>
<SheetContent className="w-full sm:max-w-xl gap-0">
<SheetHeader className="border-b">
<SheetTitle>
<Trans>Bulk Add {{ foo: t`Network Probes` }}</Trans>
</SheetTitle>
<SheetDescription>
<Trans>
Paste one probe per line. See{" "}
<a href={"#bulk-add-probes-docs"} className="underline underline-offset-2">
the documentation
</a>
.
</Trans>
</SheetDescription>
</SheetHeader>
<form onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
<div className="flex-1 space-y-4 overflow-auto p-4">
{!systemId && (
<div className="grid gap-2">
<Label>
<Trans>System</Trans>
</Label>
<Select value={bulkSelectedSystemId} onValueChange={setBulkSelectedSystemId} required>
<SelectTrigger>
<SelectValue placeholder={t`Select a system`} />
</SelectTrigger>
<SelectContent>
{systems.map((sys) => (
<SelectItem key={sys.id} value={sys.id}>
{sys.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="bulk-probes">
<Trans>Entries</Trans>
</Label>
<Textarea
id="bulk-probes"
value={bulkInput}
onChange={(e) => setBulkInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
handleBulkSubmit(e)
}
}}
className="h-120 font-mono text-sm bg-muted/40"
style={{ maxHeight: `calc(100vh - 20rem)` }}
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60,Homepage"].join("\n")}
required
/>
<p className="text-xs text-muted-foreground">
target[,protocol[,port[,interval[,name]]]] TCP and HTTP default to port 443.
</p>
</div>
</div>
<SheetFooter className="border-t">
<Button type="submit" disabled={bulkLoading || (!systemId && !bulkSelectedSystemId)}>
<Trans>Add {{ foo: t`Network Probes` }}</Trans>
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
</>
)
}

View File

@@ -41,7 +41,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr
ref={ref}
className={cn(
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted!",
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted/40",
className
)}
{...props}