mirror of
https://github.com/henrygd/beszel.git
synced 2026-05-06 10:51:50 +02:00
updates
This commit is contained in:
@@ -22,6 +22,7 @@ export type DataPoint<T = SystemStatsRecord> = {
|
|||||||
order?: number
|
order?: number
|
||||||
strokeOpacity?: number
|
strokeOpacity?: number
|
||||||
activeDot?: boolean
|
activeDot?: boolean
|
||||||
|
dot?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LineChartDefault({
|
export default function LineChartDefault({
|
||||||
@@ -42,7 +43,6 @@ export default function LineChartDefault({
|
|||||||
truncate = false,
|
truncate = false,
|
||||||
chartProps,
|
chartProps,
|
||||||
connectNulls,
|
connectNulls,
|
||||||
dot = false,
|
|
||||||
}: {
|
}: {
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||||
@@ -65,7 +65,6 @@ export default function LineChartDefault({
|
|||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
||||||
connectNulls?: boolean
|
connectNulls?: boolean
|
||||||
dot?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
@@ -87,7 +86,7 @@ export default function LineChartDefault({
|
|||||||
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||||
|
|
||||||
// Use a stable key derived from data point identities and visual properties
|
// 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)
|
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
|
||||||
|
|
||||||
@@ -103,7 +102,7 @@ export default function LineChartDefault({
|
|||||||
dataKey={dataPoint.dataKey}
|
dataKey={dataPoint.dataKey}
|
||||||
name={dataPoint.label}
|
name={dataPoint.label}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
dot={dot}
|
dot={dataPoint.dot || false}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeOpacity={dataPoint.strokeOpacity}
|
strokeOpacity={dataPoint.strokeOpacity}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { toast } from "@/components/ui/use-toast"
|
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { SystemStatus } from "@/lib/enums"
|
import { SystemStatus } from "@/lib/enums"
|
||||||
@@ -40,23 +38,7 @@ const protocolColors: Record<string, string> = {
|
|||||||
http: "bg-green-500/15 text-green-400",
|
http: "bg-green-500/15 text-green-400",
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteProbe(id: string) {
|
const SYSTEM_STATUS_COLORS = {
|
||||||
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 = {
|
|
||||||
[SystemStatus.Up]: "bg-green-500",
|
[SystemStatus.Up]: "bg-green-500",
|
||||||
[SystemStatus.Down]: "bg-red-500",
|
[SystemStatus.Down]: "bg-red-500",
|
||||||
[SystemStatus.Paused]: "bg-primary/40",
|
[SystemStatus.Paused]: "bg-primary/40",
|
||||||
@@ -72,7 +54,15 @@ const isMuted = (record: NetworkProbeRecord, systemRecord: SystemRecord | undefi
|
|||||||
export function getProbeColumns(
|
export function getProbeColumns(
|
||||||
longestName = 0,
|
longestName = 0,
|
||||||
longestTarget = 0,
|
longestTarget = 0,
|
||||||
|
{
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSetEnabled,
|
||||||
|
}: {
|
||||||
onEdit?: (probe: NetworkProbeRecord) => void
|
onEdit?: (probe: NetworkProbeRecord) => void
|
||||||
|
onDelete?: (probes: NetworkProbeRecord[]) => void | Promise<void>
|
||||||
|
onSetEnabled?: (probes: NetworkProbeRecord[], enabled: boolean) => void | Promise<void>
|
||||||
|
} = {}
|
||||||
): ColumnDef<NetworkProbeRecord>[] {
|
): ColumnDef<NetworkProbeRecord>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -101,11 +91,17 @@ export function getProbeColumns(
|
|||||||
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
||||||
accessorFn: (record) => record.name || record.target,
|
accessorFn: (record) => record.name || record.target,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ row, getValue }) => {
|
||||||
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
|
const probe = row.original
|
||||||
|
return (
|
||||||
|
<div className="ms-1.5 max-w-40 flex gap-2 items-center truncate tabular-nums">
|
||||||
|
<span className={cn("shrink-0 size-2 rounded-full", probe.enabled ? "bg-green-500" : "bg-primary/40")} />
|
||||||
|
<div className="block" style={{ width: `${longestName / 1.05}ch` }}>
|
||||||
{getValue() as string}
|
{getValue() as string}
|
||||||
</div>
|
</div>
|
||||||
),
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
@@ -125,7 +121,7 @@ export function getProbeColumns(
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<span className="ms-1.5 xl:w-20 truncate flex items-center gap-2">
|
<span className="ms-1.5 xl:w-20 truncate flex items-center gap-2">
|
||||||
<span className={cn("shrink-0 size-2 rounded-full", STATUS_COLORS[status])} />
|
<span className={cn("shrink-0 size-2 rounded-full", SYSTEM_STATUS_COLORS[status])} />
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -238,31 +234,33 @@ export function getProbeColumns(
|
|||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
size: 40,
|
size: 40,
|
||||||
cell: ({ row }) => {
|
cell: ({ row, table }) => {
|
||||||
const { enabled } = row.original
|
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 (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="size-10">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-10"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
<Trans>Open menu</Trans>
|
<Trans>Open menu</Trans>
|
||||||
</span>
|
</span>
|
||||||
<MoreHorizontalIcon className="w-5" />
|
<MoreHorizontalIcon className="w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
<DropdownMenuContent align="end">
|
||||||
|
{!isBulkAction && (
|
||||||
<DropdownMenuItem onClick={() => onEdit?.(row.original)}>
|
<DropdownMenuItem onClick={() => onEdit?.(row.original)}>
|
||||||
<PenBoxIcon className="me-2.5 size-4" />
|
<PenBoxIcon className="me-2.5 size-4" />
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setProbeEnabled(row.original.id, !enabled)}>
|
)}
|
||||||
{enabled ? (
|
<DropdownMenuItem onClick={() => onSetEnabled?.(actionRows, !shouldPause)}>
|
||||||
|
{shouldPause ? (
|
||||||
<>
|
<>
|
||||||
<PauseCircleIcon className="me-2.5 size-4" />
|
<PauseCircleIcon className="me-2.5 size-4" />
|
||||||
<Trans>Pause</Trans>
|
<Trans>Pause</Trans>
|
||||||
@@ -276,9 +274,8 @@ export function getProbeColumns(
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(event) => {
|
onClick={() => {
|
||||||
event.stopPropagation()
|
onDelete?.(actionRows)
|
||||||
deleteProbe(row.original.id)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="me-2.5 size-4" />
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
@@ -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<NetworkProbeRecord, unknown>) {
|
function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) {
|
||||||
const probe = cell.row.original
|
const probe = cell.row.original
|
||||||
const systemRecord = useStore($allSystemsById)[probe.system]
|
const systemRecord = useStore($allSystemsById)[probe.system]
|
||||||
@@ -304,10 +308,10 @@ function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) {
|
|||||||
let color = "bg-green-500"
|
let color = "bg-green-500"
|
||||||
if (muted) {
|
if (muted) {
|
||||||
color = "bg-muted-foreground/50"
|
color = "bg-muted-foreground/50"
|
||||||
} else if (responseTime > 200) {
|
} else if (responseTime > responseTimeThresholds[probe.protocol].warning) {
|
||||||
color = "bg-yellow-500"
|
color = "bg-yellow-500"
|
||||||
}
|
}
|
||||||
if (!muted && responseTime > 2000) {
|
if (!muted && responseTime > responseTimeThresholds[probe.protocol].critical) {
|
||||||
color = "bg-red-500"
|
color = "bg-red-500"
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
import { memo, useCallback, 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 { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -36,7 +35,6 @@ import { isReadOnlyUser } from "@/lib/api"
|
|||||||
import { pb } 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, EditProbeDialog } from "./probe-dialog"
|
import { AddProbeDialog, EditProbeDialog } from "./probe-dialog"
|
||||||
|
|
||||||
@@ -57,6 +55,7 @@ export default function NetworkProbesTableNew({
|
|||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([])
|
||||||
const [editingProbe, setEditingProbe] = useState<NetworkProbeRecord>()
|
const [editingProbe, setEditingProbe] = useState<NetworkProbeRecord>()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const canManageProbes = !isReadOnlyUser()
|
const canManageProbes = !isReadOnlyUser()
|
||||||
@@ -71,26 +70,12 @@ export default function NetworkProbesTableNew({
|
|||||||
return { longestName, longestTarget }
|
return { longestName, longestTarget }
|
||||||
}, [probes])
|
}, [probes])
|
||||||
|
|
||||||
// Filter columns based on whether systemId is provided
|
const runProbeBatch = useCallback(
|
||||||
const columns = useMemo(() => {
|
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
|
||||||
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 {
|
|
||||||
let batch = pb.createBatch()
|
let batch = pb.createBatch()
|
||||||
let inBatch = 0
|
let inBatch = 0
|
||||||
for (const id of selectedIds) {
|
for (const id of ids) {
|
||||||
batch.collection("network_probes").delete(id)
|
enqueue(batch, id)
|
||||||
inBatch++
|
inBatch++
|
||||||
if (inBatch >= 20) {
|
if (inBatch >= 20) {
|
||||||
await batch.send()
|
await batch.send()
|
||||||
@@ -101,7 +86,46 @@ export default function NetworkProbesTableNew({
|
|||||||
if (inBatch) {
|
if (inBatch) {
|
||||||
await batch.send()
|
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) {
|
} catch (err: unknown) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
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({
|
const table = useReactTable({
|
||||||
data: probes,
|
data: probes,
|
||||||
columns,
|
columns,
|
||||||
@@ -162,17 +231,36 @@ 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 && (
|
{probes.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">
|
<Input
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
placeholder={t`Filter...`}
|
||||||
<AlertDialogTrigger asChild>
|
value={globalFilter}
|
||||||
<Button variant="destructive" className="h-9 shrink-0">
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
<Trash2Icon className="size-4 shrink-0" />
|
className="ms-auto px-4 w-full max-w-full md:w-50"
|
||||||
<span className="ms-1">
|
/>
|
||||||
<Trans>Delete</Trans>
|
)}
|
||||||
</span>
|
{canManageProbes ? <AddProbeDialog systemId={systemId} /> : null}
|
||||||
</Button>
|
{canManageProbes ? (
|
||||||
</AlertDialogTrigger>
|
<EditProbeDialog
|
||||||
|
systemId={systemId}
|
||||||
|
probe={editingProbe}
|
||||||
|
open={!!editingProbe}
|
||||||
|
setOpen={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingProbe(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDeleteOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setPendingDeleteIds([])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
@@ -196,29 +284,6 @@ export default function NetworkProbesTableNew({
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</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-50"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canManageProbes ? <AddProbeDialog systemId={systemId} /> : null}
|
|
||||||
{canManageProbes ? (
|
|
||||||
<EditProbeDialog
|
|
||||||
systemId={systemId}
|
|
||||||
probe={editingProbe}
|
|
||||||
open={!!editingProbe}
|
|
||||||
setOpen={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setEditingProbe(undefined)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="rounded-md">
|
<div className="rounded-md">
|
||||||
|
|||||||
@@ -34,31 +34,88 @@ type ProbeValues = {
|
|||||||
name?: string
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Schema = v.object({
|
type NormalizedProbeValues = Omit<ProbeValues, "system" | "interval"> & {
|
||||||
system: v.string(),
|
interval: number
|
||||||
target: v.string(),
|
}
|
||||||
protocol: v.picklist(["icmp", "tcp", "http"]),
|
|
||||||
|
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(),
|
port: v.number(),
|
||||||
interval: v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600)),
|
interval: ProbeIntervalSchema,
|
||||||
enabled: v.boolean(),
|
name: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
name: v.optional(v.string()),
|
}),
|
||||||
|
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) {
|
function buildProbePayload(values: ProbeValues) {
|
||||||
const normalizedPort = (values.protocol === "tcp" || values.protocol === "http") && !values.port ? 443 : values.port
|
const normalizedValues = v.safeParse(NormalizedProbeValuesSchema, values)
|
||||||
const payload = v.parse(Schema, {
|
if (!normalizedValues.success) {
|
||||||
|
throw new Error(normalizedValues.issues[0]?.message || "Invalid probe")
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
system: values.system,
|
system: values.system,
|
||||||
target: values.target,
|
|
||||||
protocol: values.protocol,
|
|
||||||
port: normalizedPort,
|
|
||||||
interval: values.interval,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
...normalizedValues.output,
|
||||||
const trimmedName = values.name?.trim()
|
}
|
||||||
const targetName = values.target.replace(/^https?:\/\//i, "")
|
|
||||||
|
const trimmedName = normalizedValues.output.name?.trim()
|
||||||
|
const targetName = normalizedValues.output.target.replace(/^https?:\/\//i, "")
|
||||||
if (trimmedName) {
|
if (trimmedName) {
|
||||||
payload.name = trimmedName
|
payload.name = trimmedName
|
||||||
} else if (targetName !== values.target) {
|
} else if (targetName !== normalizedValues.output.target) {
|
||||||
payload.name = targetName
|
payload.name = targetName
|
||||||
} else {
|
} else {
|
||||||
payload.name = ""
|
payload.name = ""
|
||||||
@@ -68,40 +125,25 @@ function buildProbePayload(values: ProbeValues) {
|
|||||||
|
|
||||||
function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
|
function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
|
||||||
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",")
|
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",")
|
||||||
const target = rawTarget.trim()
|
const parsed = v.safeParse(BulkProbeSchema, {
|
||||||
if (!target) {
|
target: rawTarget,
|
||||||
throw new Error(`Line ${lineNumber}: target is required`)
|
protocol: rawProtocol,
|
||||||
}
|
port: rawPort,
|
||||||
|
interval: rawInterval,
|
||||||
const inferredProtocol: ProbeProtocol = /^https?:\/\//i.test(target) ? "http" : "icmp"
|
name: rawName.join(","),
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid probe entry"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildProbePayload({
|
return buildProbePayload({
|
||||||
system,
|
system,
|
||||||
target,
|
target: parsed.output.target,
|
||||||
protocol: protocolValue,
|
protocol: (parsed.output.protocol?.toLowerCase() ||
|
||||||
port: 0,
|
(/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as ProbeProtocol,
|
||||||
interval: rawInterval.trim() || "30",
|
port: parsed.output.port ? Number(parsed.output.port) : 0,
|
||||||
name: rawName.join(",").trim() || undefined,
|
interval: parsed.output.interval || "30",
|
||||||
|
name: parsed.output.name || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +361,9 @@ function ProbeDialogContent({
|
|||||||
}) {
|
}) {
|
||||||
const [protocol, setProtocol] = useState<ProbeProtocol>(probe?.protocol ?? "icmp")
|
const [protocol, setProtocol] = useState<ProbeProtocol>(probe?.protocol ?? "icmp")
|
||||||
const [target, setTarget] = useState(probe?.target ?? "")
|
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 [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? 30))
|
||||||
const [name, setName] = useState(probe?.name ?? "")
|
const [name, setName] = useState(probe?.name ?? "")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -343,7 +387,7 @@ function ProbeDialogContent({
|
|||||||
system: selectedSystem,
|
system: selectedSystem,
|
||||||
target,
|
target,
|
||||||
protocol,
|
protocol,
|
||||||
port: protocol === "tcp" ? Number(port) : 0,
|
port: protocol === "tcp" || protocol === "http" ? Number(port) : 0,
|
||||||
interval: probeInterval,
|
interval: probeInterval,
|
||||||
name,
|
name,
|
||||||
})
|
})
|
||||||
@@ -417,7 +461,7 @@ function ProbeDialogContent({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{protocol === "tcp" && (
|
{(protocol === "tcp" || protocol === "http") && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
<Trans>Port</Trans>
|
<Trans>Port</Trans>
|
||||||
@@ -429,7 +473,7 @@ function ProbeDialogContent({
|
|||||||
placeholder="443"
|
placeholder="443"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
required
|
required={protocol === "tcp"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function ProbeChart({
|
|||||||
.split(" ")
|
.split(" ")
|
||||||
.filter((term) => term.length > 0)
|
.filter((term) => term.length > 0)
|
||||||
: []
|
: []
|
||||||
|
const dot = chartData.chartTime === "1m"
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const p = sortedProbes[i]
|
const p = sortedProbes[i]
|
||||||
const label = p.name || p.target
|
const label = p.name || p.target
|
||||||
@@ -65,11 +66,12 @@ function ProbeChart({
|
|||||||
order: i,
|
order: i,
|
||||||
label,
|
label,
|
||||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[p.id]?.[valueIndex] ?? "-",
|
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[p.id]?.[valueIndex] ?? "-",
|
||||||
|
dot,
|
||||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { dataPoints: points, visibleKeys: visibleIDs }
|
return { dataPoints: points, visibleKeys: visibleIDs }
|
||||||
}, [probes, filter, valueIndex])
|
}, [probes, filter, valueIndex, chartData.chartTime])
|
||||||
|
|
||||||
const filteredProbeStats = useMemo(() => {
|
const filteredProbeStats = useMemo(() => {
|
||||||
if (!visibleKeys.length) return probeStats
|
if (!visibleKeys.length) return probeStats
|
||||||
@@ -97,7 +99,6 @@ function ProbeChart({
|
|||||||
contentFormatter={contentFormatter}
|
contentFormatter={contentFormatter}
|
||||||
legend={legend}
|
legend={legend}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
dot={chartData.chartTime === "1m"}
|
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
||||||
const lastCreated = cachedProbeStats.at(-1)?.created
|
const lastCreated = cachedProbeStats.at(-1)?.created
|
||||||
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
||||||
console.log("Using cached probe stats, skipping fetch")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,13 +218,13 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|||||||
.subscribe(
|
.subscribe(
|
||||||
`rt_metrics`,
|
`rt_metrics`,
|
||||||
(data: { Probes: NetworkProbeStatsRecord["stats"] }) => {
|
(data: { Probes: NetworkProbeStatsRecord["stats"] }) => {
|
||||||
let prev = getCacheValue(systemId, "rt")
|
const prev = getCacheValue(systemId, "rt")
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
// if no previous data or the last data point is older than 1min,
|
// 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
|
// 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) {
|
// if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) {
|
||||||
prev = [{ created: now - 2000, stats: probesToStats(probes) }]
|
// prev = [{ created: now - 30_000, stats: probesToStats(probes) }]
|
||||||
}
|
// }
|
||||||
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
|
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
|
||||||
const newStats = appendData(prev, [stats], 1000, 120)
|
const newStats = appendData(prev, [stats], 1000, 120)
|
||||||
setProbeStats(() => newStats)
|
setProbeStats(() => newStats)
|
||||||
@@ -245,14 +244,14 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
|
// function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
|
||||||
const stats: NetworkProbeStatsRecord["stats"] = {}
|
// const stats: NetworkProbeStatsRecord["stats"] = {}
|
||||||
for (const probe of probes) {
|
// for (const probe of probes) {
|
||||||
// TODO: include only if probe.updated < charttime
|
// // TODO: include only if probe.updated < charttime
|
||||||
stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h]
|
// stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h]
|
||||||
}
|
// }
|
||||||
return stats
|
// return stats
|
||||||
}
|
// }
|
||||||
|
|
||||||
async function fetchProbes(systemId?: string) {
|
async function fetchProbes(systemId?: string) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user