diff --git a/internal/hub/api.go b/internal/hub/api.go index f7a3d0a3..d2d009c0 100644 --- a/internal/hub/api.go +++ b/internal/hub/api.go @@ -134,11 +134,6 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // get container info apiAuth.GET("/containers/info", h.getContainerInfo) } - // network probe routes - apiAuth.GET("/network-probes", h.listNetworkProbes) - apiAuth.POST("/network-probes", h.createNetworkProbe).BindFunc(excludeReadOnlyRole) - apiAuth.DELETE("/network-probes", h.deleteNetworkProbe).BindFunc(excludeReadOnlyRole) - apiAuth.GET("/network-probe-stats", h.getNetworkProbeStats) return nil } diff --git a/internal/hub/api_probes.go b/internal/hub/api_probes.go deleted file mode 100644 index 51e0a9de..00000000 --- a/internal/hub/api_probes.go +++ /dev/null @@ -1,201 +0,0 @@ -package hub - -import ( - "encoding/json" - "net/http" - "strings" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" -) - -// listNetworkProbes handles GET /api/beszel/network-probes -func (h *Hub) listNetworkProbes(e *core.RequestEvent) error { - systemID := e.Request.URL.Query().Get("system") - if systemID == "" { - return e.BadRequestError("system parameter required", nil) - } - system, err := h.sm.GetSystem(systemID) - if err != nil || !system.HasUser(e.App, e.Auth) { - return e.NotFoundError("", nil) - } - - records, err := e.App.FindRecordsByFilter( - "network_probes", - "system = {:system}", - "-created", - 0, 0, - dbx.Params{"system": systemID}, - ) - if err != nil { - return e.InternalServerError("", err) - } - - type probeRecord struct { - Id string `json:"id"` - Name string `json:"name"` - Target string `json:"target"` - Protocol string `json:"protocol"` - Port int `json:"port"` - Interval int `json:"interval"` - Enabled bool `json:"enabled"` - } - - result := make([]probeRecord, 0, len(records)) - for _, r := range records { - result = append(result, probeRecord{ - Id: r.Id, - Name: r.GetString("name"), - Target: r.GetString("target"), - Protocol: r.GetString("protocol"), - Port: r.GetInt("port"), - Interval: r.GetInt("interval"), - Enabled: r.GetBool("enabled"), - }) - } - - return e.JSON(http.StatusOK, result) -} - -// createNetworkProbe handles POST /api/beszel/network-probes -func (h *Hub) createNetworkProbe(e *core.RequestEvent) error { - var req struct { - System string `json:"system"` - Name string `json:"name"` - Target string `json:"target"` - Protocol string `json:"protocol"` - Port int `json:"port"` - Interval int `json:"interval"` - } - if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil { - return e.BadRequestError("invalid request body", err) - } - if req.System == "" || req.Target == "" || req.Protocol == "" { - return e.BadRequestError("system, target, and protocol are required", nil) - } - if req.Protocol != "icmp" && req.Protocol != "tcp" && req.Protocol != "http" { - return e.BadRequestError("protocol must be icmp, tcp, or http", nil) - } - if req.Protocol == "http" && !strings.HasPrefix(req.Target, "http://") && !strings.HasPrefix(req.Target, "https://") { - return e.BadRequestError("http probe target must start with http:// or https://", nil) - } - if req.Interval <= 0 { - req.Interval = 10 - } - - system, err := h.sm.GetSystem(req.System) - if err != nil || !system.HasUser(e.App, e.Auth) { - return e.NotFoundError("", nil) - } - - collection, err := e.App.FindCachedCollectionByNameOrId("network_probes") - if err != nil { - return e.InternalServerError("", err) - } - - record := core.NewRecord(collection) - record.Set("system", req.System) - record.Set("name", req.Name) - record.Set("target", req.Target) - record.Set("protocol", req.Protocol) - record.Set("port", req.Port) - record.Set("interval", req.Interval) - record.Set("enabled", true) - - if err := e.App.Save(record); err != nil { - return e.InternalServerError("", err) - } - - // Sync probes to agent - h.syncProbesToAgent(req.System) - - return e.JSON(http.StatusOK, map[string]string{"id": record.Id}) -} - -// deleteNetworkProbe handles DELETE /api/beszel/network-probes -func (h *Hub) deleteNetworkProbe(e *core.RequestEvent) error { - probeID := e.Request.URL.Query().Get("id") - if probeID == "" { - return e.BadRequestError("id parameter required", nil) - } - - record, err := e.App.FindRecordById("network_probes", probeID) - if err != nil { - return e.NotFoundError("", nil) - } - - systemID := record.GetString("system") - system, err := h.sm.GetSystem(systemID) - if err != nil || !system.HasUser(e.App, e.Auth) { - return e.NotFoundError("", nil) - } - - if err := e.App.Delete(record); err != nil { - return e.InternalServerError("", err) - } - - // Sync probes to agent - h.syncProbesToAgent(systemID) - - return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) -} - -// getNetworkProbeStats handles GET /api/beszel/network-probe-stats -func (h *Hub) getNetworkProbeStats(e *core.RequestEvent) error { - systemID := e.Request.URL.Query().Get("system") - statsType := e.Request.URL.Query().Get("type") - if systemID == "" { - return e.BadRequestError("system parameter required", nil) - } - if statsType == "" { - statsType = "1m" - } - - system, err := h.sm.GetSystem(systemID) - if err != nil || !system.HasUser(e.App, e.Auth) { - return e.NotFoundError("", nil) - } - - records, err := e.App.FindRecordsByFilter( - "network_probe_stats", - "system = {:system} && type = {:type}", - "created", - 0, 0, - dbx.Params{"system": systemID, "type": statsType}, - ) - if err != nil { - return e.InternalServerError("", err) - } - - type statsRecord struct { - Stats json.RawMessage `json:"stats"` - Created string `json:"created"` - } - - result := make([]statsRecord, 0, len(records)) - for _, r := range records { - statsJSON, _ := json.Marshal(r.Get("stats")) - result = append(result, statsRecord{ - Stats: statsJSON, - Created: r.GetDateTime("created").Time().UTC().Format("2006-01-02 15:04:05.000Z"), - }) - } - - return e.JSON(http.StatusOK, result) -} - -// syncProbesToAgent fetches enabled probes for a system and sends them to the agent. -func (h *Hub) syncProbesToAgent(systemID string) { - system, err := h.sm.GetSystem(systemID) - if err != nil { - return - } - - configs := h.sm.GetProbeConfigsForSystem(systemID) - - go func() { - if err := system.SyncNetworkProbes(configs); err != nil { - h.Logger().Warn("failed to sync probes to agent", "system", systemID, "err", err) - } - }() -} diff --git a/internal/hub/collections.go b/internal/hub/collections.go index 5b939a44..6133fd90 100644 --- a/internal/hub/collections.go +++ b/internal/hub/collections.go @@ -92,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error { return err } - if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{ + if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{ list: &systemScopedReadRule, view: &systemScopedReadRule, create: &systemScopedWriteRule, diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 13cd7e6a..06d4f1a9 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -81,6 +81,7 @@ func (h *Hub) StartHub() error { } // register middlewares h.registerMiddlewares(e) + // bind events that aren't set up in different // register api routes if err := h.registerApiRoutes(e); err != nil { return err @@ -109,6 +110,8 @@ func (h *Hub) StartHub() error { h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole) h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings) + bindNetworkProbesEvents(h) + pb, ok := h.App.(*pocketbase.PocketBase) if !ok { return errors.New("not a pocketbase app") diff --git a/internal/hub/probes.go b/internal/hub/probes.go new file mode 100644 index 00000000..849fe70d --- /dev/null +++ b/internal/hub/probes.go @@ -0,0 +1,36 @@ +package hub + +import ( + "github.com/pocketbase/pocketbase/core" +) + +func bindNetworkProbesEvents(h *Hub) { + // sync probe to agent on creation + h.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error { + systemID := e.Record.GetString("system") + h.syncProbesToAgent(systemID) + return e.Next() + }) + // sync probe to agent on delete + h.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error { + systemID := e.Record.GetString("system") + h.syncProbesToAgent(systemID) + return e.Next() + }) +} + +// syncProbesToAgent fetches enabled probes for a system and sends them to the agent. +func (h *Hub) syncProbesToAgent(systemID string) { + system, err := h.sm.GetSystem(systemID) + if err != nil { + return + } + + configs := h.sm.GetProbeConfigsForSystem(systemID) + + go func() { + if err := system.SyncNetworkProbes(configs); err != nil { + h.Logger().Warn("failed to sync probes to agent", "system", systemID, "err", err) + } + }() +} diff --git a/internal/migrations/1776632983_updated_network_probes.go b/internal/migrations/1776632983_updated_network_probes.go new file mode 100644 index 00000000..fc23f257 --- /dev/null +++ b/internal/migrations/1776632983_updated_network_probes.go @@ -0,0 +1,62 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("np_probes_001") + if err != nil { + return err + } + + // add field + if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{ + "hidden": false, + "id": "number926446584", + "max": null, + "min": null, + "name": "latency", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }`)); err != nil { + return err + } + + // add field + if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{ + "hidden": false, + "id": "number3726709001", + "max": null, + "min": null, + "name": "loss", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }`)); err != nil { + return err + } + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("np_probes_001") + if err != nil { + return err + } + + // remove field + collection.Fields.RemoveById("number926446584") + + // remove field + collection.Fields.RemoveById("number3726709001") + + return app.Save(collection) + }) +} 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 new file mode 100644 index 00000000..c6fa5a1a --- /dev/null +++ b/internal/site/src/components/network-probes-table/network-probes-columns.tsx @@ -0,0 +1,211 @@ +import type { Column, ColumnDef } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { cn, decimalString, hourWithSeconds } from "@/lib/utils" +import { + GlobeIcon, + TagIcon, + TimerIcon, + ActivityIcon, + WifiOffIcon, + Trash2Icon, + ArrowLeftRightIcon, + MoreHorizontalIcon, + ServerIcon, + ClockIcon, +} from "lucide-react" +import { t } from "@lingui/core/macro" +import type { NetworkProbeRecord } from "@/types" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Trans } from "@lingui/react/macro" +import { pb } from "@/lib/api" +import { toast } from "../ui/use-toast" +import { $allSystemsById } from "@/lib/stores" +import { useStore } from "@nanostores/react" + +// export interface ProbeRow extends NetworkProbeRecord { +// key: string +// latency?: number +// loss?: number +// } + +const protocolColors: Record = { + icmp: "bg-blue-500/15 text-blue-400", + tcp: "bg-purple-500/15 text-purple-400", + 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 }) + } +} + +export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef[] { + return [ + { + id: "name", + 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} +
+ ), + }, + { + id: "system", + accessorFn: (record) => record.system, + sortingFn: (a, b) => { + const allSystems = $allSystemsById.get() + const systemNameA = allSystems[a.original.system]?.name ?? "" + const systemNameB = allSystems[b.original.system]?.name ?? "" + return systemNameA.localeCompare(systemNameB) + }, + header: ({ column }) => , + cell: ({ getValue }) => { + const allSystems = useStore($allSystemsById) + return {allSystems[getValue() as string]?.name ?? ""} + }, + }, + { + id: "target", + sortingFn: (a, b) => a.original.target.localeCompare(b.original.target), + accessorFn: (record) => record.target, + header: ({ column }) => , + cell: ({ getValue }) => ( +
+ {getValue() as string} +
+ ), + }, + { + id: "protocol", + accessorFn: (record) => record.protocol, + header: ({ column }) => , + cell: ({ getValue }) => { + const protocol = getValue() as string + return ( + + {protocol} + + ) + }, + }, + { + id: "interval", + accessorFn: (record) => record.interval, + header: ({ column }) => , + cell: ({ getValue }) => {getValue() as number}s, + }, + { + id: "latency", + accessorFn: (record) => record.latency, + invertSorting: true, + header: ({ column }) => , + cell: ({ row }) => { + const val = row.original.latency + if (val === undefined) { + return - + } + return ( + + 100 ? "bg-yellow-500" : "bg-green-500")} /> + {decimalString(val, val < 100 ? 2 : 1).toLocaleString()} ms + + ) + }, + }, + { + id: "loss", + accessorFn: (record) => record.loss, + invertSorting: true, + header: ({ column }) => , + cell: ({ row }) => { + const val = row.original.loss + if (val === undefined) { + return - + } + return ( + + 0 ? "bg-yellow-500" : "bg-green-500")} /> + {val}% + + ) + }, + }, + { + id: "updated", + invertSorting: true, + accessorFn: (record) => record.updated, + header: ({ column }) => , + cell: ({ getValue }) => { + const timestamp = getValue() as number + return {hourWithSeconds(new Date(timestamp).toISOString())} + }, + }, + { + id: "actions", + enableSorting: false, + header: () => null, + size: 40, + cell: ({ row }) => ( + + + + + event.stopPropagation()}> + { + event.stopPropagation() + deleteProbe(row.original.id) + }} + > + + Delete + + + + ), + }, + ] +} + +function HeaderButton({ + column, + name, + Icon, +}: { + column: Column + name: string + Icon: React.ElementType +}) { + const isSorted = column.getIsSorted() + 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 new file mode 100644 index 00000000..e2dcd93c --- /dev/null +++ b/internal/site/src/components/network-probes-table/network-probes-table.tsx @@ -0,0 +1,310 @@ +import { t } from "@lingui/core/macro" +import { Trans } from "@lingui/react/macro" +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type Row, + type SortingState, + type Table as TableType, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table" +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" +import { listenKeys } from "nanostores" +import { memo, useEffect, 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" +import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { isReadOnlyUser, pb } from "@/lib/api" +import { $allSystemsById } from "@/lib/stores" +import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils" +import type { NetworkProbeRecord } from "@/types" +import { AddProbeDialog } from "./probe-dialog" + +const NETWORK_PROBE_FIELDS = "id,name,system,target,protocol,port,interval,enabled,updated" + +export default function NetworkProbesTableNew({ systemId }: { systemId?: string }) { + const loadTime = Date.now() + const [data, setData] = useState([]) + const [sorting, setSorting] = useBrowserStorage( + `sort-np-${systemId ? 1 : 0}`, + [{ id: systemId ? "name" : "system", desc: false }], + sessionStorage + ) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [globalFilter, setGlobalFilter] = useState("") + + // clear old data when systemId changes + useEffect(() => { + return setData([]) + }, [systemId]) + + useEffect(() => { + function fetchData(systemId?: string) { + pb.collection("network_probes") + .getList(0, 2000, { + fields: NETWORK_PROBE_FIELDS, + filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, + }) + .then((res) => setData(res.items)) + } + + // initial load + fetchData(systemId) + + // if no systemId, pull after every system update + if (!systemId) { + return $allSystemsById.listen((_value, _oldValue, systemId) => { + // exclude initial load of systems + if (Date.now() - loadTime > 500) { + fetchData(systemId) + } + }) + } + + // if systemId, fetch after the system is updated + return listenKeys($allSystemsById, [systemId], (_newSystems) => { + fetchData(systemId) + }) + }, [systemId]) + + // Subscribe to updates + useEffect(() => { + let unsubscribe: (() => void) | undefined + const pbOptions = systemId + ? { fields: NETWORK_PROBE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) } + : { fields: NETWORK_PROBE_FIELDS } + + ;(async () => { + try { + unsubscribe = await pb.collection("network_probes").subscribe( + "*", + (event) => { + const record = event.record + setData((currentProbes) => { + const probes = currentProbes ?? [] + const matchesSystemScope = !systemId || record.system === systemId + + if (event.action === "delete") { + return probes.filter((device) => device.id !== record.id) + } + + if (!matchesSystemScope) { + // Record moved out of scope; ensure it disappears locally. + return probes.filter((device) => device.id !== record.id) + } + + const existingIndex = probes.findIndex((device) => device.id === record.id) + if (existingIndex === -1) { + return [record, ...probes] + } + + const next = [...probes] + next[existingIndex] = record + return next + }) + }, + pbOptions + ) + } catch (error) { + console.error("Failed to subscribe to SMART device updates:", error) + } + })() + + return () => { + unsubscribe?.() + } + }, [systemId]) + + const { longestName, longestTarget } = useMemo(() => { + let longestName = 0 + let longestTarget = 0 + for (const p of data) { + longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target)) + longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target)) + } + return { longestName, longestTarget } + }, [data]) + + // Filter columns based on whether systemId is provided + const columns = useMemo(() => { + let columns = getProbeColumns(longestName, longestTarget) + columns = systemId ? columns.filter((col) => col.id !== "system") : columns + columns = isReadOnlyUser() ? columns.filter((col) => col.id !== "actions") : columns + return columns + }, [systemId, longestName, longestTarget]) + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + defaultColumn: { + sortUndefined: "last", + size: 900, + minSize: 0, + }, + state: { + sorting, + columnFilters, + columnVisibility, + globalFilter, + }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue) => { + const probe = row.original + const systemName = $allSystemsById.get()[probe.system]?.name ?? "" + const searchString = `${probe.name}${probe.target}${probe.protocol}${systemName}`.toLocaleLowerCase() + return (filterValue as string) + .toLowerCase() + .split(" ") + .every((term) => searchString.includes(term)) + }, + }) + + const rows = table.getRowModel().rows + const visibleColumns = table.getVisibleLeafColumns() + + if (!data.length && !globalFilter) { + return null + } + + return ( + + +
+
+ + Network Probes + +
+ ICMP/TCP/HTTP latency monitoring from this agent +
+
+
+ {data.length > 0 && ( + setGlobalFilter(e.target.value)} + className="ms-auto px-4 w-full max-w-full md:w-64" + /> + )} + {systemId && !isReadOnlyUser() ? : null} +
+
+
+
+ +
+
+ ) +} + +const NetworkProbesTable = memo(function NetworkProbeTable({ + table, + rows, + colLength, +}: { + table: TableType + rows: Row[] + colLength: number +}) { + // The virtualizer will need a reference to the scrollable container element + const scrollRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 54, + getScrollElement: () => scrollRef.current, + overscan: 5, + }) + const virtualRows = virtualizer.getVirtualItems() + + const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) + const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) + + return ( +
2) && "min-h-50" + )} + ref={scrollRef} + > + {/* add header height to table size */} +
+ + + + {rows.length ? ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + return + }) + ) : ( + + + No results. + + + )} + +
+
+
+ ) +}) + +function NetworkProbeTableHead({ table }: { table: TableType }) { + return ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + ) +} + +const NetworkProbeTableRow = memo(function NetworkProbeTableRow({ + row, + virtualRow, +}: { + row: Row + virtualRow: VirtualItem +}) { + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +}) diff --git a/internal/site/src/components/routes/system/probe-dialog.tsx b/internal/site/src/components/network-probes-table/probe-dialog.tsx similarity index 82% rename from internal/site/src/components/routes/system/probe-dialog.tsx rename to internal/site/src/components/network-probes-table/probe-dialog.tsx index c944875b..05ca5f94 100644 --- a/internal/site/src/components/routes/system/probe-dialog.tsx +++ b/internal/site/src/components/network-probes-table/probe-dialog.tsx @@ -17,12 +17,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { PlusIcon } from "lucide-react" import { useToast } from "@/components/ui/use-toast" -export function AddProbeDialog({ systemId, onCreated }: { systemId: string; onCreated: () => void }) { +export function AddProbeDialog({ systemId }: { systemId: string }) { const [open, setOpen] = useState(false) const [protocol, setProtocol] = useState("icmp") const [target, setTarget] = useState("") const [port, setPort] = useState("") - const [probeInterval, setProbeInterval] = useState("10") + const [probeInterval, setProbeInterval] = useState("60") const [name, setName] = useState("") const [loading, setLoading] = useState(false) const { toast } = useToast() @@ -32,7 +32,7 @@ export function AddProbeDialog({ systemId, onCreated }: { systemId: string; onCr setProtocol("icmp") setTarget("") setPort("") - setProbeInterval("10") + setProbeInterval("60") setName("") } @@ -40,20 +40,16 @@ export function AddProbeDialog({ systemId, onCreated }: { systemId: string; onCr e.preventDefault() setLoading(true) try { - await pb.send("/api/beszel/network-probes", { - method: "POST", - body: { - system: systemId, - name, - target, - protocol, - port: protocol === "tcp" ? Number(port) : 0, - interval: Number(probeInterval), - }, + await pb.collection("network_probes").create({ + system: systemId, + name, + target, + protocol, + port: protocol === "tcp" ? Number(port) : 0, + interval: Number(probeInterval), }) resetForm() setOpen(false) - onCreated() } catch (err: unknown) { toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message }) } finally { @@ -64,21 +60,21 @@ export function AddProbeDialog({ systemId, onCreated }: { systemId: string; onCr return ( - - + - Add Network Probe + Add {{ foo: t`Network Probe` }} Configure ICMP, TCP, or HTTP latency monitoring from this agent. -
+
diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 4f65ccb9..17edaeb5 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -11,7 +11,13 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts" import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts" -import { LazyContainersTable, LazyNetworkProbesTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables" +import { + LazyContainersTable, + LazyNetworkProbesTable, + LazySmartTable, + LazySystemdTable, + LazyNetworkProbesTableNew, +} from "./system/lazy-tables" import { LoadAverageChart } from "./system/charts/load-average-chart" import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react" import { GpuIcon } from "../ui/icons" @@ -147,7 +153,9 @@ export default memo(function SystemDetail({ id }: { id: string }) { {hasSystemd && } - + + + {/* */} ) } @@ -195,7 +203,8 @@ export default memo(function SystemDetail({ id }: { id: string }) { {pageBottomExtraMargin > 0 &&
} - + + {/* */} diff --git a/internal/site/src/components/routes/system/lazy-tables.tsx b/internal/site/src/components/routes/system/lazy-tables.tsx index 976bd537..02a8df10 100644 --- a/internal/site/src/components/routes/system/lazy-tables.tsx +++ b/internal/site/src/components/routes/system/lazy-tables.tsx @@ -35,6 +35,16 @@ export function LazySystemdTable({ systemId }: { systemId: string }) { ) } +const NetworkProbesTableNew = lazy(() => import("@/components/network-probes-table/network-probes-table")) + +export function LazyNetworkProbesTableNew({ systemId }: { systemId: string }) { + const { isIntersecting, ref } = useIntersectionObserver() + return ( +
+ {isIntersecting && } +
+ ) +} const NetworkProbesTable = lazy(() => import("@/components/routes/system/network-probes")) export function LazyNetworkProbesTable({ diff --git a/internal/site/src/components/routes/system/network-probes.tsx b/internal/site/src/components/routes/system/network-probes.tsx index 236d99f7..7c141209 100644 --- a/internal/site/src/components/routes/system/network-probes.tsx +++ b/internal/site/src/components/routes/system/network-probes.tsx @@ -49,10 +49,12 @@ export default function NetworkProbes({ const { t } = useLingui() const fetchProbes = useCallback(() => { - pb.send("/api/beszel/network-probes", { - query: { system: systemId }, - }) - .then(setProbes) + pb.collection("network_probes") + .getList(0, 2000, { + fields: "id,name,target,protocol,port,interval,enabled,updated", + filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, + }) + .then((res) => setProbes(res.items)) .catch(() => setProbes([])) }, [systemId]) @@ -143,16 +145,13 @@ export default function NetworkProbes({ const deleteProbe = useCallback( async (id: string) => { try { - await pb.send("/api/beszel/network-probes", { - method: "DELETE", - query: { id }, - }) - fetchProbes() - } catch (err: any) { - toast({ variant: "destructive", title: t`Error`, description: err?.message }) + await pb.collection("network_probes").delete(id) + // fetchProbes() + } catch (err: unknown) { + toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message }) } }, - [fetchProbes, toast, t] + [systemId, t] ) const dataPoints: DataPoint[] = useMemo(() => { diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 15802e57..69b4cf0d 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -549,12 +549,16 @@ export interface UpdateInfo { export interface NetworkProbeRecord { id: string + system: string name: string target: string protocol: "icmp" | "tcp" | "http" port: number + latency: number + loss: number interval: number enabled: boolean + updated: string } export interface NetworkProbeStatsRecord {