This commit is contained in:
henrygd
2026-04-19 19:12:04 -04:00
parent 40da2b4358
commit ea19ef6334
13 changed files with 676 additions and 242 deletions

View File

@@ -134,11 +134,6 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// get container info // get container info
apiAuth.GET("/containers/info", h.getContainerInfo) 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 return nil
} }

View File

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

View File

@@ -92,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error {
return err return err
} }
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{ if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{
list: &systemScopedReadRule, list: &systemScopedReadRule,
view: &systemScopedReadRule, view: &systemScopedReadRule,
create: &systemScopedWriteRule, create: &systemScopedWriteRule,

View File

@@ -81,6 +81,7 @@ func (h *Hub) StartHub() error {
} }
// register middlewares // register middlewares
h.registerMiddlewares(e) h.registerMiddlewares(e)
// bind events that aren't set up in different
// register api routes // register api routes
if err := h.registerApiRoutes(e); err != nil { if err := h.registerApiRoutes(e); err != nil {
return err return err
@@ -109,6 +110,8 @@ func (h *Hub) StartHub() error {
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole) h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings) h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
bindNetworkProbesEvents(h)
pb, ok := h.App.(*pocketbase.PocketBase) pb, ok := h.App.(*pocketbase.PocketBase)
if !ok { if !ok {
return errors.New("not a pocketbase app") return errors.New("not a pocketbase app")

36
internal/hub/probes.go Normal file
View File

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

View File

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

View File

@@ -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<string, string> = {
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<NetworkProbeRecord>[] {
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 }) => <HeaderButton column={column} name={t`Name`} Icon={TagIcon} />,
cell: ({ getValue }) => (
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
{getValue() as string}
</div>
),
},
{
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 }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
{
id: "target",
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
accessorFn: (record) => record.target,
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
cell: ({ getValue }) => (
<div className="ms-1.5 tabular-nums block truncate max-w-44" style={{ width: `${longestTarget / 1.05}ch` }}>
{getValue() as string}
</div>
),
},
{
id: "protocol",
accessorFn: (record) => record.protocol,
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => {
const protocol = getValue() as string
return (
<span className={cn("ms-1.5 px-2 py-0.5 rounded text-xs font-medium uppercase", protocolColors[protocol])}>
{protocol}
</span>
)
},
},
{
id: "interval",
accessorFn: (record) => record.interval,
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={TimerIcon} />,
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
},
{
id: "latency",
accessorFn: (record) => record.latency,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Latency`} Icon={ActivityIcon} />,
cell: ({ row }) => {
const val = row.original.latency
if (val === undefined) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", val > 100 ? "bg-yellow-500" : "bg-green-500")} />
{decimalString(val, val < 100 ? 2 : 1).toLocaleString()} ms
</span>
)
},
},
{
id: "loss",
accessorFn: (record) => record.loss,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Loss`} Icon={WifiOffIcon} />,
cell: ({ row }) => {
const val = row.original.loss
if (val === undefined) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", val > 0 ? "bg-yellow-500" : "bg-green-500")} />
{val}%
</span>
)
},
},
{
id: "updated",
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => {
const timestamp = getValue() as number
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
},
},
{
id: "actions",
enableSorting: false,
header: () => null,
size: 40,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-10"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
deleteProbe(row.original.id)
}}
>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
}
function HeaderButton({
column,
name,
Icon,
}: {
column: Column<NetworkProbeRecord>
name: string
Icon: React.ElementType
}) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="size-4" />}
{name}
{/* <ArrowUpDownIcon className="size-4" /> */}
</Button>
)
}

View File

@@ -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<NetworkProbeRecord[]>([])
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-np-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }],
sessionStorage
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [globalFilter, setGlobalFilter] = useState("")
// clear old data when systemId changes
useEffect(() => {
return setData([])
}, [systemId])
useEffect(() => {
function fetchData(systemId?: string) {
pb.collection<NetworkProbeRecord>("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<NetworkProbeRecord>("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 (
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Network Probes</Trans>
</CardTitle>
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
<Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
</div>
</div>
<div className="md:ms-auto flex items-center gap-2">
{data.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-64"
/>
)}
{systemId && !isReadOnlyUser() ? <AddProbeDialog systemId={systemId} /> : null}
</div>
</div>
</CardHeader>
<div className="rounded-md">
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
</Card>
)
}
const NetworkProbesTable = memo(function NetworkProbeTable({
table,
rows,
colLength,
}: {
table: TableType<NetworkProbeRecord>
rows: Row<NetworkProbeRecord>[]
colLength: number
}) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
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 (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<NetworkProbeTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <NetworkProbeTableRow key={row.id} row={row} virtualRow={virtualRow} />
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
<Trans>No results.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
</div>
)
})
function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</tr>
))}
</TableHeader>
)
}
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
row,
virtualRow,
}: {
row: Row<NetworkProbeRecord>
virtualRow: VirtualItem
}) {
return (
<TableRow data-state={row.getIsSelected() && "selected"} className="transition-opacity">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0"
style={{
width: `${cell.column.getSize()}px`,
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})

View File

@@ -17,12 +17,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { PlusIcon } from "lucide-react" import { PlusIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast" 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 [open, setOpen] = useState(false)
const [protocol, setProtocol] = useState<string>("icmp") const [protocol, setProtocol] = useState<string>("icmp")
const [target, setTarget] = useState("") const [target, setTarget] = useState("")
const [port, setPort] = useState("") const [port, setPort] = useState("")
const [probeInterval, setProbeInterval] = useState("10") const [probeInterval, setProbeInterval] = useState("60")
const [name, setName] = useState("") const [name, setName] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { toast } = useToast() const { toast } = useToast()
@@ -32,7 +32,7 @@ export function AddProbeDialog({ systemId, onCreated }: { systemId: string; onCr
setProtocol("icmp") setProtocol("icmp")
setTarget("") setTarget("")
setPort("") setPort("")
setProbeInterval("10") setProbeInterval("60")
setName("") setName("")
} }
@@ -40,20 +40,16 @@ export function AddProbeDialog({ systemId, onCreated }: { systemId: string; onCr
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
try { try {
await pb.send("/api/beszel/network-probes", { await pb.collection("network_probes").create({
method: "POST", system: systemId,
body: { name,
system: systemId, target,
name, protocol,
target, port: protocol === "tcp" ? Number(port) : 0,
protocol, interval: Number(probeInterval),
port: protocol === "tcp" ? Number(port) : 0,
interval: Number(probeInterval),
},
}) })
resetForm() resetForm()
setOpen(false) setOpen(false)
onCreated()
} catch (err: unknown) { } catch (err: unknown) {
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message }) toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
} finally { } finally {
@@ -64,21 +60,21 @@ export function AddProbeDialog({ systemId, onCreated }: { systemId: string; onCr
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline">
<PlusIcon className="size-4 me-1" /> <PlusIcon className="size-4 me-1" />
<Trans>Add Probe</Trans> <Trans>Add {{ foo: t`Probe` }}</Trans>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-md"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Add Network Probe</Trans> <Trans>Add {{ foo: t`Network Probe` }}</Trans>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<Trans>Configure ICMP, TCP, or HTTP latency monitoring from this agent.</Trans> <Trans>Configure ICMP, TCP, or HTTP latency monitoring from this agent.</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4"> <form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
<Trans>Target</Trans> <Trans>Target</Trans>
@@ -146,7 +142,7 @@ export function AddProbeDialog({ systemId, onCreated }: { systemId: string; onCr
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{loading ? <Trans>Creating...</Trans> : <Trans>Create</Trans>} {loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@@ -11,7 +11,13 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts" import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-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 { LoadAverageChart } from "./system/charts/load-average-chart"
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react" import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
import { GpuIcon } from "../ui/icons" import { GpuIcon } from "../ui/icons"
@@ -147,7 +153,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{hasSystemd && <LazySystemdTable systemId={system.id} />} {hasSystemd && <LazySystemdTable systemId={system.id} />}
<LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> <LazyNetworkProbesTableNew systemId={system.id} />
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
</> </>
) )
} }
@@ -195,7 +203,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} /> <SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>} {pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div> </div>
<LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> <LazyNetworkProbesTableNew systemId={system.id} />
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
</TabsContent> </TabsContent>
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}> <TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>

View File

@@ -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 (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <NetworkProbesTableNew systemId={systemId} />}
</div>
)
}
const NetworkProbesTable = lazy(() => import("@/components/routes/system/network-probes")) const NetworkProbesTable = lazy(() => import("@/components/routes/system/network-probes"))
export function LazyNetworkProbesTable({ export function LazyNetworkProbesTable({

View File

@@ -49,10 +49,12 @@ export default function NetworkProbes({
const { t } = useLingui() const { t } = useLingui()
const fetchProbes = useCallback(() => { const fetchProbes = useCallback(() => {
pb.send<NetworkProbeRecord[]>("/api/beszel/network-probes", { pb.collection<NetworkProbeRecord>("network_probes")
query: { system: systemId }, .getList(0, 2000, {
}) fields: "id,name,target,protocol,port,interval,enabled,updated",
.then(setProbes) filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
.then((res) => setProbes(res.items))
.catch(() => setProbes([])) .catch(() => setProbes([]))
}, [systemId]) }, [systemId])
@@ -143,16 +145,13 @@ export default function NetworkProbes({
const deleteProbe = useCallback( const deleteProbe = useCallback(
async (id: string) => { async (id: string) => {
try { try {
await pb.send("/api/beszel/network-probes", { await pb.collection("network_probes").delete(id)
method: "DELETE", // fetchProbes()
query: { id }, } catch (err: unknown) {
}) toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
fetchProbes()
} catch (err: any) {
toast({ variant: "destructive", title: t`Error`, description: err?.message })
} }
}, },
[fetchProbes, toast, t] [systemId, t]
) )
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => { const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {

View File

@@ -549,12 +549,16 @@ export interface UpdateInfo {
export interface NetworkProbeRecord { export interface NetworkProbeRecord {
id: string id: string
system: string
name: string name: string
target: string target: string
protocol: "icmp" | "tcp" | "http" protocol: "icmp" | "tcp" | "http"
port: number port: number
latency: number
loss: number
interval: number interval: number
enabled: boolean enabled: boolean
updated: string
} }
export interface NetworkProbeStatsRecord { export interface NetworkProbeStatsRecord {