mirror of
https://github.com/henrygd/beszel.git
synced 2026-05-06 19:01:48 +02:00
updates
This commit is contained in:
@@ -22,6 +22,7 @@ export type DataPoint<T = SystemStatsRecord> = {
|
||||
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<React.ComponentProps<typeof LineChart>, "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}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<void>
|
||||
onSetEnabled?: (probes: NetworkProbeRecord[], enabled: boolean) => void | Promise<void>
|
||||
} = {}
|
||||
): ColumnDef<NetworkProbeRecord>[] {
|
||||
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 }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row, getValue }) => {
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
@@ -125,7 +121,7 @@ export function getProbeColumns(
|
||||
return useMemo(
|
||||
() => (
|
||||
<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}
|
||||
</span>
|
||||
),
|
||||
@@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="size-10">
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={() => onEdit?.(row.original)}>
|
||||
<PenBoxIcon className="me-2.5 size-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setProbeEnabled(row.original.id, !enabled)}>
|
||||
{enabled ? (
|
||||
<DropdownMenuContent align="end">
|
||||
{!isBulkAction && (
|
||||
<DropdownMenuItem onClick={() => onEdit?.(row.original)}>
|
||||
<PenBoxIcon className="me-2.5 size-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onSetEnabled?.(actionRows, !shouldPause)}>
|
||||
{shouldPause ? (
|
||||
<>
|
||||
<PauseCircleIcon className="me-2.5 size-4" />
|
||||
<Trans>Pause</Trans>
|
||||
@@ -276,9 +274,8 @@ export function getProbeColumns(
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteProbe(row.original.id)
|
||||
onClick={() => {
|
||||
onDelete?.(actionRows)
|
||||
}}
|
||||
>
|
||||
<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>) {
|
||||
const probe = cell.row.original
|
||||
const systemRecord = useStore($allSystemsById)[probe.system]
|
||||
@@ -304,10 +308,10 @@ function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) {
|
||||
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 (
|
||||
|
||||
@@ -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<RowSelectionState>({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([])
|
||||
const [editingProbe, setEditingProbe] = useState<NetworkProbeRecord>()
|
||||
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<typeof pb.createBatch>, 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({
|
||||
</div>
|
||||
</div>
|
||||
<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 && (
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
@@ -218,6 +252,37 @@ export default function NetworkProbesTableNew({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<AlertDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDeleteOpen(open)
|
||||
if (!open) {
|
||||
setPendingDeleteIds([])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -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<ProbeValues, "system" | "interval"> & {
|
||||
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<ProbeProtocol>(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({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{protocol === "tcp" && (
|
||||
{(protocol === "tcp" || protocol === "http") && (
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Port</Trans>
|
||||
@@ -429,7 +473,7 @@ function ProbeDialogContent({
|
||||
placeholder="443"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
required={protocol === "tcp"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
</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
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user