diff --git a/internal/site/src/components/charts/line-chart.tsx b/internal/site/src/components/charts/line-chart.tsx index c9501c8b..b8947674 100644 --- a/internal/site/src/components/charts/line-chart.tsx +++ b/internal/site/src/components/charts/line-chart.tsx @@ -22,6 +22,7 @@ export type DataPoint = { order?: number strokeOpacity?: number activeDot?: boolean + dot?: boolean } export default function LineChartDefault({ @@ -42,7 +43,6 @@ export default function LineChartDefault({ truncate = false, chartProps, connectNulls, - dot = false, }: { chartData: ChartData // biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData) @@ -65,7 +65,6 @@ export default function LineChartDefault({ truncate?: boolean chartProps?: Omit, "data" | "margin"> connectNulls?: boolean - dot?: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) @@ -87,7 +86,7 @@ export default function LineChartDefault({ }, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData]) // Use a stable key derived from data point identities and visual properties - const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0") + const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity}${d.dot}`).join("\0") const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created) @@ -103,7 +102,7 @@ export default function LineChartDefault({ dataKey={dataPoint.dataKey} name={dataPoint.label} type="monotoneX" - dot={dot} + dot={dataPoint.dot || false} strokeWidth={1.5} stroke={color} strokeOpacity={dataPoint.strokeOpacity} 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 cec104a8..d77f1532 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 @@ -26,8 +26,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Trans } from "@lingui/react/macro" -import { pb } from "@/lib/api" -import { toast } from "@/components/ui/use-toast" import { $allSystemsById } from "@/lib/stores" import { useStore } from "@nanostores/react" import { SystemStatus } from "@/lib/enums" @@ -40,23 +38,7 @@ const protocolColors: Record = { http: "bg-green-500/15 text-green-400", } -async function deleteProbe(id: string) { - try { - await pb.collection("network_probes").delete(id) - } catch (err: unknown) { - toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message }) - } -} - -async function setProbeEnabled(id: string, enabled: boolean) { - try { - await pb.collection("network_probes").update(id, { enabled }) - } catch (err: unknown) { - toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message }) - } -} - -const STATUS_COLORS = { +const SYSTEM_STATUS_COLORS = { [SystemStatus.Up]: "bg-green-500", [SystemStatus.Down]: "bg-red-500", [SystemStatus.Paused]: "bg-primary/40", @@ -72,7 +54,15 @@ const isMuted = (record: NetworkProbeRecord, systemRecord: SystemRecord | undefi export function getProbeColumns( longestName = 0, longestTarget = 0, - onEdit?: (probe: NetworkProbeRecord) => void + { + onEdit, + onDelete, + onSetEnabled, + }: { + onEdit?: (probe: NetworkProbeRecord) => void + onDelete?: (probes: NetworkProbeRecord[]) => void | Promise + onSetEnabled?: (probes: NetworkProbeRecord[], enabled: boolean) => void | Promise + } = {} ): ColumnDef[] { return [ { @@ -101,11 +91,17 @@ export function getProbeColumns( sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target), accessorFn: (record) => record.name || record.target, header: ({ column }) => , - cell: ({ getValue }) => ( -
- {getValue() as string} -
- ), + cell: ({ row, getValue }) => { + const probe = row.original + return ( +
+ +
+ {getValue() as string} +
+
+ ) + }, }, { id: "system", @@ -125,7 +121,7 @@ export function getProbeColumns( return useMemo( () => ( - + {name} ), @@ -238,31 +234,33 @@ export function getProbeColumns( enableHiding: false, header: () => null, size: 40, - cell: ({ row }) => { - const { enabled } = row.original + cell: ({ row, table }) => { + const selectedRows = table.getSelectedRowModel().rows + const actionRows = + row.getIsSelected() && selectedRows.length > 1 + ? selectedRows.map((selectedRow) => selectedRow.original) + : [row.original] + const isBulkAction = actionRows.length > 1 + const shouldPause = actionRows.some((probe) => probe.enabled) return ( - - event.stopPropagation()}> - onEdit?.(row.original)}> - - Edit - - setProbeEnabled(row.original.id, !enabled)}> - {enabled ? ( + + {!isBulkAction && ( + onEdit?.(row.original)}> + + Edit + + )} + onSetEnabled?.(actionRows, !shouldPause)}> + {shouldPause ? ( <> Pause @@ -276,9 +274,8 @@ export function getProbeColumns( { - event.stopPropagation() - deleteProbe(row.original.id) + onClick={() => { + onDelete?.(actionRows) }} > @@ -291,6 +288,13 @@ export function getProbeColumns( }, ] } + +const responseTimeThresholds = { + http: { warning: 800, critical: 3000 }, + tcp: { warning: 500, critical: 2000 }, + icmp: { warning: 100, critical: 500 }, +} + function responseTimeCell(cell: CellContext) { const probe = cell.row.original const systemRecord = useStore($allSystemsById)[probe.system] @@ -304,10 +308,10 @@ function responseTimeCell(cell: CellContext) { let color = "bg-green-500" if (muted) { color = "bg-muted-foreground/50" - } else if (responseTime > 200) { + } else if (responseTime > responseTimeThresholds[probe.protocol].warning) { color = "bg-yellow-500" } - if (!muted && responseTime > 2000) { + if (!muted && responseTime > responseTimeThresholds[probe.protocol].critical) { color = "bg-red-500" } return ( 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 c001ef40..57c0a6a7 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 @@ -23,10 +23,9 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from "@/components/ui/alert-dialog" -import { Button, buttonVariants } from "@/components/ui/button" -import { memo, useMemo, useRef, useState } from "react" +import { 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" @@ -36,7 +35,6 @@ 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, EditProbeDialog } from "./probe-dialog" @@ -57,6 +55,7 @@ export default function NetworkProbesTableNew({ const [rowSelection, setRowSelection] = useState({}) const [globalFilter, setGlobalFilter] = useState("") const [deleteOpen, setDeleteOpen] = useState(false) + const [pendingDeleteIds, setPendingDeleteIds] = useState([]) const [editingProbe, setEditingProbe] = useState() const { toast } = useToast() const canManageProbes = !isReadOnlyUser() @@ -71,26 +70,12 @@ export default function NetworkProbesTableNew({ return { longestName, longestTarget } }, [probes]) - // Filter columns based on whether systemId is provided - const columns = useMemo(() => { - let columns = getProbeColumns(longestName, longestTarget, setEditingProbe) - columns = systemId ? columns.filter((col) => col.id !== "system") : columns - columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions") - return columns - }, [systemId, longestName, longestTarget]) - - const handleBulkDelete = async () => { - setDeleteOpen(false) - const selectedIds = Object.keys(rowSelection) - if (!selectedIds.length) { - return - } - - try { + const runProbeBatch = useCallback( + async (ids: string[], enqueue: (batch: ReturnType, id: string) => void) => { let batch = pb.createBatch() let inBatch = 0 - for (const id of selectedIds) { - batch.collection("network_probes").delete(id) + for (const id of ids) { + enqueue(batch, id) inBatch++ if (inBatch >= 20) { await batch.send() @@ -101,7 +86,46 @@ export default function NetworkProbesTableNew({ if (inBatch) { await batch.send() } - table.resetRowSelection() + }, + [] + ) + + 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", @@ -111,6 +135,51 @@ export default function NetworkProbesTableNew({ } } + 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, @@ -162,41 +231,6 @@ 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 && ( ) : null} + { + setDeleteOpen(open) + if (!open) { + setPendingDeleteIds([]) + } + }} + > + + + + Are you sure? + + + This will permanently delete all selected records from the database. + + + + + Cancel + + + Continue + + + +
diff --git a/internal/site/src/components/network-probes-table/probe-dialog.tsx b/internal/site/src/components/network-probes-table/probe-dialog.tsx index d82c53d9..56251281 100644 --- a/internal/site/src/components/network-probes-table/probe-dialog.tsx +++ b/internal/site/src/components/network-probes-table/probe-dialog.tsx @@ -34,31 +34,88 @@ type ProbeValues = { name?: string } -const Schema = v.object({ - system: v.string(), - target: v.string(), - protocol: v.picklist(["icmp", "tcp", "http"]), - port: v.number(), - interval: v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600)), - enabled: v.boolean(), - name: v.optional(v.string()), +type NormalizedProbeValues = Omit & { + interval: number +} + +const ProbeProtocolSchema = v.picklist(["icmp", "tcp", "http"]) + +const ProbeIntervalSchema = v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600)) + +// Both the single-probe form and the bulk importer flow through this schema so +// defaults and HTTP target normalization stay in one place. +const NormalizedProbeValuesSchema = v.pipe( + v.object({ + target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")), + protocol: ProbeProtocolSchema, + port: v.number(), + interval: ProbeIntervalSchema, + name: v.optional(v.pipe(v.string(), v.trim())), + }), + v.transform((input): NormalizedProbeValues => { + let { protocol, port } = input + if (protocol === "icmp") { + port = 0 + } else if ((protocol === "tcp" || protocol === "http") && !port) { + port = 443 + } + return { + // HTTP probes may be entered as bare hostnames, so normalize them to a + // scheme-bearing URL before the payload is sent to PocketBase. + target: protocol === "http" ? normalizeHttpTarget(input.target, port) : input.target, + protocol, + port, + interval: input.interval, + name: input.name || undefined, + } + }), + v.forward( + v.check((input) => { + if (input.protocol === "icmp") { + return input.port === 0 + } + + return Number.isInteger(input.port) && input.port >= 1 && input.port <= 65535 + }, "Port must be between 1 and 65535"), + ["port"] + ) +) + +// Bulk parsing only trims raw CSV fields. Inference, defaults, and protocol- +// specific validation still go through the shared normalization schema above. +const BulkProbeSchema = v.object({ + target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")), + protocol: v.optional(v.pipe(v.string(), v.trim())), + port: v.optional(v.pipe(v.string(), v.trim())), + interval: v.optional(v.pipe(v.string(), v.trim())), + name: v.optional(v.pipe(v.string(), v.trim())), }) +function normalizeHttpTarget(target: string, port: number) { + if (/^https?:\/\//i.test(target)) { + return target + } + + return `${port === 443 ? "https" : "http"}://${target}` +} + function buildProbePayload(values: ProbeValues) { - const normalizedPort = (values.protocol === "tcp" || values.protocol === "http") && !values.port ? 443 : values.port - const payload = v.parse(Schema, { + const normalizedValues = v.safeParse(NormalizedProbeValuesSchema, values) + if (!normalizedValues.success) { + throw new Error(normalizedValues.issues[0]?.message || "Invalid probe") + } + + const payload = { 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, "") + ...normalizedValues.output, + } + + const trimmedName = normalizedValues.output.name?.trim() + const targetName = normalizedValues.output.target.replace(/^https?:\/\//i, "") if (trimmedName) { payload.name = trimmedName - } else if (targetName !== values.target) { + } else if (targetName !== normalizedValues.output.target) { payload.name = targetName } else { payload.name = "" @@ -68,40 +125,25 @@ function buildProbePayload(values: ProbeValues) { 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, - }) + const parsed = v.safeParse(BulkProbeSchema, { + target: rawTarget, + protocol: rawProtocol, + port: rawPort, + interval: rawInterval, + name: rawName.join(","), + }) + if (!parsed.success) { + throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid probe entry"}`) } return buildProbePayload({ system, - target, - protocol: protocolValue, - port: 0, - interval: rawInterval.trim() || "30", - name: rawName.join(",").trim() || undefined, + target: parsed.output.target, + protocol: (parsed.output.protocol?.toLowerCase() || + (/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as ProbeProtocol, + port: parsed.output.port ? Number(parsed.output.port) : 0, + interval: parsed.output.interval || "30", + name: parsed.output.name || undefined, }) } @@ -319,7 +361,9 @@ function ProbeDialogContent({ }) { const [protocol, setProtocol] = useState(probe?.protocol ?? "icmp") const [target, setTarget] = useState(probe?.target ?? "") - const [port, setPort] = useState(probe?.protocol === "tcp" && probe.port ? String(probe.port) : "") + const [port, setPort] = useState( + (probe?.protocol === "tcp" || probe?.protocol === "http") && probe.port ? String(probe.port) : "" + ) const [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? 30)) const [name, setName] = useState(probe?.name ?? "") const [loading, setLoading] = useState(false) @@ -343,7 +387,7 @@ function ProbeDialogContent({ system: selectedSystem, target, protocol, - port: protocol === "tcp" ? Number(port) : 0, + port: protocol === "tcp" || protocol === "http" ? Number(port) : 0, interval: probeInterval, name, }) @@ -417,7 +461,7 @@ function ProbeDialogContent({ - {protocol === "tcp" && ( + {(protocol === "tcp" || protocol === "http") && (
)} diff --git a/internal/site/src/components/routes/system/charts/probes-charts.tsx b/internal/site/src/components/routes/system/charts/probes-charts.tsx index f0541952..df7fe719 100644 --- a/internal/site/src/components/routes/system/charts/probes-charts.tsx +++ b/internal/site/src/components/routes/system/charts/probes-charts.tsx @@ -53,6 +53,7 @@ function ProbeChart({ .split(" ") .filter((term) => term.length > 0) : [] + const dot = chartData.chartTime === "1m" for (let i = 0; i < count; i++) { const p = sortedProbes[i] const label = p.name || p.target @@ -65,11 +66,12 @@ function ProbeChart({ order: i, label, dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[p.id]?.[valueIndex] ?? "-", + dot, color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`, }) } return { dataPoints: points, visibleKeys: visibleIDs } - }, [probes, filter, valueIndex]) + }, [probes, filter, valueIndex, chartData.chartTime]) const filteredProbeStats = useMemo(() => { if (!visibleKeys.length) return probeStats @@ -97,7 +99,6 @@ function ProbeChart({ contentFormatter={contentFormatter} legend={legend} filter={filter} - dot={chartData.chartTime === "1m"} /> ) diff --git a/internal/site/src/lib/use-network-probes.ts b/internal/site/src/lib/use-network-probes.ts index 06406a5b..4082c3b9 100644 --- a/internal/site/src/lib/use-network-probes.ts +++ b/internal/site/src/lib/use-network-probes.ts @@ -191,7 +191,6 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) { // Skip the fetch if the latest cached point is recent enough that no new point is expected yet const lastCreated = cachedProbeStats.at(-1)?.created if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) { - console.log("Using cached probe stats, skipping fetch") return } } @@ -219,13 +218,13 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) { .subscribe( `rt_metrics`, (data: { Probes: NetworkProbeStatsRecord["stats"] }) => { - let prev = getCacheValue(systemId, "rt") + const prev = getCacheValue(systemId, "rt") const now = Date.now() // if no previous data or the last data point is older than 1min, // create a new data set starting with a point 1 second ago to seed the chart data - if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) { - prev = [{ created: now - 2000, stats: probesToStats(probes) }] - } + // if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) { + // prev = [{ created: now - 30_000, stats: probesToStats(probes) }] + // } const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord const newStats = appendData(prev, [stats], 1000, 120) setProbeStats(() => newStats) @@ -245,14 +244,14 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) { } } -function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] { - const stats: NetworkProbeStatsRecord["stats"] = {} - for (const probe of probes) { - // TODO: include only if probe.updated < charttime - stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h] - } - return stats -} +// function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] { +// const stats: NetworkProbeStatsRecord["stats"] = {} +// for (const probe of probes) { +// // TODO: include only if probe.updated < charttime +// stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h] +// } +// return stats +// } async function fetchProbes(systemId?: string) { try {