This commit is contained in:
henrygd
2026-04-25 18:43:47 -04:00
parent 9896bcdf43
commit 89ac8dc585
6 changed files with 288 additions and 176 deletions

View File

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

View File

@@ -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,
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` }}>
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()}>
<DropdownMenuContent align="end">
{!isBulkAction && (
<DropdownMenuItem onClick={() => onEdit?.(row.original)}>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setProbeEnabled(row.original.id, !enabled)}>
{enabled ? (
)}
<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 (

View File

@@ -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,17 +231,36 @@ 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>
{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}
<AlertDialog
open={deleteOpen}
onOpenChange={(open) => {
setDeleteOpen(open)
if (!open) {
setPendingDeleteIds([])
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
@@ -196,29 +284,6 @@ export default function NetworkProbesTableNew({
</AlertDialogContent>
</AlertDialog>
</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>
</CardHeader>
<div className="rounded-md">

View File

@@ -34,31 +34,88 @@ type ProbeValues = {
name?: string
}
const Schema = v.object({
system: v.string(),
target: v.string(),
protocol: v.picklist(["icmp", "tcp", "http"]),
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: v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600)),
enabled: v.boolean(),
name: v.optional(v.string()),
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>
)}

View File

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

View File

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