mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-23 21:21:49 +02:00
updates
This commit is contained in:
@@ -157,6 +157,7 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
size: 40,
|
size: 40,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
|||||||
@@ -2,25 +2,43 @@ import { t } from "@lingui/core/macro"
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import {
|
import {
|
||||||
type ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
|
type ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type Row,
|
type Row,
|
||||||
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
type Table as TableType,
|
type Table as TableType,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
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 { memo, useMemo, useRef, useState } from "react"
|
||||||
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
||||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { isReadOnlyUser } from "@/lib/api"
|
import { isReadOnlyUser } from "@/lib/api"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
|
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
|
||||||
|
import { Trash2Icon } from "lucide-react"
|
||||||
import type { NetworkProbeRecord } from "@/types"
|
import type { NetworkProbeRecord } from "@/types"
|
||||||
import { AddProbeDialog } from "./probe-dialog"
|
import { AddProbeDialog } from "./probe-dialog"
|
||||||
|
|
||||||
@@ -38,7 +56,11 @@ export default function NetworkProbesTableNew({
|
|||||||
)
|
)
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const { toast } = useToast()
|
||||||
|
const canManageProbes = !isReadOnlyUser()
|
||||||
|
|
||||||
const { longestName, longestTarget } = useMemo(() => {
|
const { longestName, longestTarget } = useMemo(() => {
|
||||||
let longestName = 0
|
let longestName = 0
|
||||||
@@ -54,19 +76,79 @@ export default function NetworkProbesTableNew({
|
|||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
let columns = getProbeColumns(longestName, longestTarget)
|
let columns = getProbeColumns(longestName, longestTarget)
|
||||||
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||||
columns = isReadOnlyUser() ? columns.filter((col) => col.id !== "actions") : columns
|
columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions")
|
||||||
return columns
|
if (!canManageProbes) {
|
||||||
}, [systemId, longestName, longestTarget])
|
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({
|
const table = useReactTable({
|
||||||
data: probes,
|
data: probes,
|
||||||
columns,
|
columns,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
sortUndefined: "last",
|
sortUndefined: "last",
|
||||||
size: 900,
|
size: 900,
|
||||||
@@ -76,6 +158,7 @@ export default function NetworkProbesTableNew({
|
|||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
globalFilter,
|
globalFilter,
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
@@ -106,20 +189,55 @@ export default function NetworkProbesTableNew({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:ms-auto flex items-center gap-2">
|
<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 && (
|
{probes.length > 0 && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="rounded-md">
|
<div className="rounded-md">
|
||||||
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
|
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} rowSelection={rowSelection} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -129,10 +247,12 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
|
|||||||
table,
|
table,
|
||||||
rows,
|
rows,
|
||||||
colLength,
|
colLength,
|
||||||
|
rowSelection: _rowSelection,
|
||||||
}: {
|
}: {
|
||||||
table: TableType<NetworkProbeRecord>
|
table: TableType<NetworkProbeRecord>
|
||||||
rows: Row<NetworkProbeRecord>[]
|
rows: Row<NetworkProbeRecord>[]
|
||||||
colLength: number
|
colLength: number
|
||||||
|
rowSelection: RowSelectionState
|
||||||
}) {
|
}) {
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -165,7 +285,14 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
|
|||||||
{rows.length ? (
|
{rows.length ? (
|
||||||
virtualRows.map((virtualRow) => {
|
virtualRows.map((virtualRow) => {
|
||||||
const row = rows[virtualRow.index]
|
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>
|
<TableRow>
|
||||||
@@ -202,12 +329,14 @@ function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord>
|
|||||||
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
||||||
row,
|
row,
|
||||||
virtualRow,
|
virtualRow,
|
||||||
|
isSelected,
|
||||||
}: {
|
}: {
|
||||||
row: Row<NetworkProbeRecord>
|
row: Row<NetworkProbeRecord>
|
||||||
virtualRow: VirtualItem
|
virtualRow: VirtualItem
|
||||||
|
isSelected: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TableRow data-state={row.getIsSelected() && "selected"} className="transition-opacity">
|
<TableRow data-state={isSelected && "selected"} className="transition-opacity">
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
|
|||||||
@@ -9,17 +9,30 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
} 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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
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 { useToast } from "@/components/ui/use-toast"
|
||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import * as v from "valibot"
|
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({
|
const Schema = v.object({
|
||||||
system: v.string(),
|
system: v.string(),
|
||||||
target: v.string(),
|
target: v.string(),
|
||||||
@@ -30,15 +43,78 @@ const Schema = v.object({
|
|||||||
name: v.optional(v.string()),
|
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 }) {
|
export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false)
|
||||||
const [protocol, setProtocol] = useState<string>("icmp")
|
const [protocol, setProtocol] = useState<string>("icmp")
|
||||||
const [target, setTarget] = useState("")
|
const [target, setTarget] = useState("")
|
||||||
const [port, setPort] = useState("")
|
const [port, setPort] = useState("")
|
||||||
const [probeInterval, setProbeInterval] = useState("30")
|
const [probeInterval, setProbeInterval] = useState("30")
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [bulkInput, setBulkInput] = useState("")
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false)
|
||||||
const [selectedSystemId, setSelectedSystemId] = useState("")
|
const [selectedSystemId, setSelectedSystemId] = useState("")
|
||||||
|
const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("")
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
@@ -53,24 +129,37 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
|||||||
setSelectedSystemId("")
|
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()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = v.parse(Schema, {
|
const payload = buildProbePayload({
|
||||||
system: systemId ?? selectedSystemId,
|
system: systemId ?? selectedSystemId,
|
||||||
target,
|
target,
|
||||||
protocol,
|
protocol: protocol as ProbeProtocol,
|
||||||
port: protocol === "tcp" ? Number(port) : 0,
|
port: protocol === "tcp" ? Number(port) : 0,
|
||||||
interval: probeInterval,
|
interval: probeInterval,
|
||||||
enabled: true,
|
name,
|
||||||
})
|
})
|
||||||
if (name) {
|
|
||||||
payload.name = name
|
|
||||||
} else if (targetName !== target) {
|
|
||||||
payload.name = targetName
|
|
||||||
}
|
|
||||||
await pb.collection("network_probes").create(payload)
|
await pb.collection("network_probes").create(payload)
|
||||||
resetForm()
|
resetForm()
|
||||||
setOpen(false)
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<>
|
||||||
<DialogTrigger asChild>
|
<div className="flex gap-0 rounded-lg">
|
||||||
<Button variant="outline">
|
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow">
|
||||||
<PlusIcon className="size-4 me-1" />
|
{/* <PlusIcon className="size-4 me-1" /> */}
|
||||||
<Trans>Add {{ foo: t`Probe` }}</Trans>
|
<Trans>Add {{ foo: t`Probe` }}</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
<div className="w-px h-full bg-muted"></div>
|
||||||
<DialogContent className="max-w-md">
|
<DropdownMenu>
|
||||||
<DialogHeader>
|
<DropdownMenuTrigger asChild>
|
||||||
<DialogTitle>
|
<Button variant="outline" className="px-2 rounded-s-none border-s-0" aria-label={t`More probe actions`}>
|
||||||
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
|
<ChevronDownIcon className="size-4" />
|
||||||
</DialogTitle>
|
</Button>
|
||||||
<DialogDescription>
|
</DropdownMenuTrigger>
|
||||||
<Trans>Configure response monitoring from this agent.</Trans>
|
<DropdownMenuContent align="end">
|
||||||
</DialogDescription>
|
<DropdownMenuItem onClick={openBulkAdd}>
|
||||||
</DialogHeader>
|
<ListIcon className="size-4 me-2" />
|
||||||
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
|
<Trans>Bulk Add</Trans>
|
||||||
{!systemId && (
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
<Trans>System</Trans>
|
<Trans>Target</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>
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
value={target}
|
||||||
value={port}
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
onChange={(e) => setPort(e.target.value)}
|
placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"}
|
||||||
placeholder="443"
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-2">
|
<Label>
|
||||||
<Label>
|
<Trans>Protocol</Trans>
|
||||||
<Trans>Interval (seconds)</Trans>
|
</Label>
|
||||||
</Label>
|
|
||||||
<Input
|
<Select value={protocol} onValueChange={setProtocol}>
|
||||||
type="number"
|
<SelectTrigger>
|
||||||
value={probeInterval}
|
<SelectValue />
|
||||||
onChange={(e) => setProbeInterval(e.target.value)}
|
</SelectTrigger>
|
||||||
min={1}
|
<SelectContent>
|
||||||
max={3600}
|
<SelectItem value="icmp">ICMP</SelectItem>
|
||||||
required
|
<SelectItem value="tcp">TCP</SelectItem>
|
||||||
/>
|
<SelectItem value="http">HTTP</SelectItem>
|
||||||
</div>
|
</SelectContent>
|
||||||
<div className="grid gap-2">
|
</Select>
|
||||||
<Label>
|
</div>
|
||||||
<Trans>Name (optional)</Trans>
|
{protocol === "tcp" && (
|
||||||
</Label>
|
<div className="grid gap-2">
|
||||||
<Input
|
<Label>
|
||||||
value={name}
|
<Trans>Port</Trans>
|
||||||
onChange={(e) => setName(e.target.value)}
|
</Label>
|
||||||
placeholder={targetName || t`e.g. Cloudflare DNS`}
|
<Input
|
||||||
/>
|
type="number"
|
||||||
</div>
|
value={port}
|
||||||
<DialogFooter>
|
onChange={(e) => setPort(e.target.value)}
|
||||||
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
|
placeholder="443"
|
||||||
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
|
min={1}
|
||||||
</Button>
|
max={65535}
|
||||||
</DialogFooter>
|
required
|
||||||
</form>
|
/>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
)}
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user