diff --git a/internal/site/src/components/network-probes-table/network-probes-columns.tsx b/internal/site/src/components/network-probes-table/network-probes-columns.tsx index 69149f9d..587aab4b 100644 --- a/internal/site/src/components/network-probes-table/network-probes-columns.tsx +++ b/internal/site/src/components/network-probes-table/network-probes-columns.tsx @@ -157,6 +157,7 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef null, size: 40, cell: ({ row }) => ( diff --git a/internal/site/src/components/network-probes-table/network-probes-table.tsx b/internal/site/src/components/network-probes-table/network-probes-table.tsx index ef0a8574..453321d8 100644 --- a/internal/site/src/components/network-probes-table/network-probes-table.tsx +++ b/internal/site/src/components/network-probes-table/network-probes-table.tsx @@ -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([]) const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) 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 = { + id: "select", + header: ({ table }) => ( + table.toggleAllRowsSelected(!!value)} + aria-label={t`Select all`} + /> + ), + cell: ({ row }) => ( + 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({
+ {canManageProbes && table.getFilteredSelectedRowModel().rows.length > 0 && ( +
+ + + + + + + + Are you sure? + + + This will permanently delete all selected records from the database. + + + + + Cancel + + + Continue + + + + +
+ )} {probes.length > 0 && ( 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() ? : null} + {canManageProbes ? : null}
- +
) @@ -129,10 +247,12 @@ const NetworkProbesTable = memo(function NetworkProbeTable({ table, rows, colLength, + rowSelection: _rowSelection, }: { table: TableType rows: Row[] colLength: number + rowSelection: RowSelectionState }) { // The virtualizer will need a reference to the scrollable container element const scrollRef = useRef(null) @@ -165,7 +285,14 @@ const NetworkProbesTable = memo(function NetworkProbeTable({ {rows.length ? ( virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] - return + return ( + + ) }) ) : ( @@ -202,12 +329,14 @@ function NetworkProbeTableHead({ table }: { table: TableType const NetworkProbeTableRow = memo(function NetworkProbeTableRow({ row, virtualRow, + isSelected, }: { row: Row virtualRow: VirtualItem + isSelected: boolean }) { return ( - + {row.getVisibleCells().map((cell) => ( 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("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 ( - - - - - - - - Add {{ foo: t`Network Probe` }} - - - Configure response monitoring from this agent. - - -
- {!systemId && ( +
+ + + + + + + + Bulk Add + + + + + + + + + Add {{ foo: t`Network Probe` }} + + + Configure response monitoring from this agent. + + + + {!systemId && ( +
+ + +
+ )}
- -
- )} -
- - setTarget(e.target.value)} - placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"} - required - /> -
-
- - - -
- {protocol === "tcp" && ( -
- 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 />
- )} -
- - setProbeInterval(e.target.value)} - min={1} - max={3600} - required - /> -
-
- - setName(e.target.value)} - placeholder={targetName || t`e.g. Cloudflare DNS`} - /> -
- - - - -
-
+
+ + + +
+ {protocol === "tcp" && ( +
+ + setPort(e.target.value)} + placeholder="443" + min={1} + max={65535} + required + /> +
+ )} +
+ + setProbeInterval(e.target.value)} + min={1} + max={3600} + required + /> +
+
+ + setName(e.target.value)} + placeholder={targetName || t`e.g. Cloudflare DNS`} + /> +
+ + + + +
+
+ + + + + + Bulk Add {{ foo: t`Network Probes` }} + + + + Paste one probe per line. See{" "} + + the documentation + + . + + + +
+
+ {!systemId && ( +
+ + +
+ )} +
+ +