mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 04:01:50 +02:00
updates
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
36
internal/hub/probes.go
Normal 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
62
internal/migrations/1776632983_updated_network_probes.go
Normal file
62
internal/migrations/1776632983_updated_network_probes.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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",
|
|
||||||
body: {
|
|
||||||
system: systemId,
|
system: systemId,
|
||||||
name,
|
name,
|
||||||
target,
|
target,
|
||||||
protocol,
|
protocol,
|
||||||
port: protocol === "tcp" ? Number(port) : 0,
|
port: protocol === "tcp" ? Number(port) : 0,
|
||||||
interval: Number(probeInterval),
|
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>
|
||||||
@@ -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"}>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||||
})
|
})
|
||||||
.then(setProbes)
|
.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(() => {
|
||||||
|
|||||||
4
internal/site/src/types.d.ts
vendored
4
internal/site/src/types.d.ts
vendored
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user