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", id: "actions",
enableSorting: false, enableSorting: false,
enableHiding: false,
header: () => null, header: () => null,
size: 40, size: 40,
cell: ({ row }) => ( cell: ({ row }) => (

View File

@@ -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}

View File

@@ -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>
</>
) )
} }

View File

@@ -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}