Compare commits

..

8 Commits

Author SHA1 Message Date
henrygd
330d375997 change to atomic.bool for fetching details / smart 2025-12-18 15:02:59 -05:00
henrygd
8627e3ee97 updates 2025-12-18 12:34:11 -05:00
henrygd
5d04ee5a65 consolidate info bar data 2025-12-17 19:03:31 -05:00
henrygd
d93067ec34 updates 2025-12-17 17:32:59 -05:00
henrygd
82bd953941 add arch 2025-12-16 18:33:32 -05:00
henrygd
996444abeb update 2025-12-16 17:45:26 -05:00
henrygd
aef4baff5e rm index 2025-12-15 18:59:25 -05:00
henrygd
3dea061e93 progress 2025-12-15 18:29:51 -05:00
13 changed files with 90 additions and 84 deletions

View File

@@ -103,7 +103,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent.dockerManager = newDockerManager()
// initialize system info
agent.refreshSystemDetails()
agent.refreshStaticInfo()
// initialize connection manager
agent.connectionManager = newConnectionManager(agent)
@@ -166,7 +166,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
Info: a.systemInfo,
}
// Include static system details only when requested
// Include static info only when requested
if options.IncludeDetails {
data.Details = &a.systemDetails
}
@@ -233,8 +233,7 @@ func (a *Agent) getFingerprint() string {
// if no fingerprint is found, generate one
fingerprint, err := host.HostID()
// we ignore a commonly known "product_uuid" known not to be unique
if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
if err != nil || fingerprint == "" {
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
}

View File

@@ -757,6 +757,7 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
slog.Error("Failed to decode Docker version response", "error", err)
return info, err
}

View File

@@ -550,9 +550,6 @@ func createTestCombinedData() *system.CombinedData {
DiskUsed: 549755813888, // 512GB
DiskPct: 50.0,
},
Details: &system.Details{
Hostname: "test-host",
},
Info: system.Info{
Uptime: 3600,
AgentVersion: "0.12.0",

View File

@@ -30,7 +30,7 @@ type prevDisk struct {
}
// Sets initial / non-changing values about the host system
func (a *Agent) refreshSystemDetails() {
func (a *Agent) refreshStaticInfo() {
a.systemInfo.AgentVersion = beszel.Version
// get host info from Docker if available
@@ -246,6 +246,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
a.systemInfo.Uptime, _ = host.Uptime()
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
a.systemInfo.Threads = a.systemDetails.Threads
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats
}

View File

@@ -34,12 +34,15 @@ type ApiStats struct {
MemoryStats MemoryStats `json:"memory_stats"`
}
// Docker system info from /info API endpoint
// Docker system info from /info
type HostInfo struct {
OperatingSystem string `json:"OperatingSystem"`
KernelVersion string `json:"KernelVersion"`
NCPU int `json:"NCPU"`
MemTotal uint64 `json:"MemTotal"`
// OSVersion string `json:"OSVersion"`
// OSType string `json:"OSType"`
// Architecture string `json:"Architecture"`
}
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {

View File

@@ -125,22 +125,22 @@ const (
// Core system data that is needed in All Systems table
type Info struct {
Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
// Threads is needed in Info struct to calculate load average thresholds
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead

View File

@@ -415,11 +415,7 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
// Wait for first value to expire
time.Sleep(time.Millisecond * 20)
// Trigger lazy cleanup of the expired key
_, ok := em.GetOk("key1")
assert.False(t, ok)
// Try to remove the remaining "value1" entry (key3)
// Try to remove the expired value - should remove one of the "value1" entries
removedValue, ok := em.RemovebyValue("value1")
assert.True(t, ok)
assert.Equal(t, "value1", removedValue)
@@ -427,9 +423,14 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
// Should still have key2 (different value)
assert.True(t, em.Has("key2"))
// key1 should be gone due to expiration and key3 should be removed by value.
assert.False(t, em.Has("key1"))
assert.False(t, em.Has("key3"))
// Should have removed one of the "value1" entries (either key1 or key3)
// But we can't predict which one due to map iteration order
key1Exists := em.Has("key1")
key3Exists := em.Has("key3")
// Exactly one of key1 or key3 should be gone
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
assert.True(t, key1Exists || key3Exists) // At least one should still exist
}
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {

View File

@@ -123,25 +123,10 @@ func (sys *System) update() error {
if !sys.detailsFetched.Load() {
options.IncludeDetails = true
}
data, err := sys.fetchDataFromAgent(options)
if err != nil {
return err
if err == nil {
_, err = sys.createRecords(data)
}
// create system records
_, err = sys.createRecords(data)
// Fetch and save SMART devices when system first comes online
if backgroundSmartFetchEnabled() && !sys.smartFetched.Load() && sys.smartFetching.CompareAndSwap(false, true) {
go func() {
defer sys.smartFetching.Store(false)
if err := sys.FetchAndSaveSmartDevices(); err == nil {
sys.smartFetched.Store(true)
}
}()
}
return err
}
@@ -223,6 +208,18 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return nil
})
// Fetch and save SMART devices when system first comes online
if err == nil {
if !sys.smartFetched.Load() && sys.smartFetching.CompareAndSwap(false, true) {
go func() {
defer sys.smartFetching.Store(false)
if err := sys.FetchAndSaveSmartDevices(); err == nil {
sys.smartFetched.Store(true)
}
}()
}
}
return systemRecord, err
}
@@ -398,7 +395,8 @@ func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, req
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: action, Data: requestData}
reqDataBytes, _ := cbor.Marshal(requestData)
req := common.HubRequest[cbor.RawMessage]{Action: action, Data: reqDataBytes}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()
var resp common.AgentResponse
@@ -462,7 +460,8 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return false, err
}
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
reqDataBytes, _ := cbor.Marshal(common.SystemdInfoRequest{ServiceName: serviceName})
req := common.HubRequest[cbor.RawMessage]{Action: common.GetSystemdInfo, Data: reqDataBytes}
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
return false, err
}
@@ -510,7 +509,8 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
*sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
req := common.HubRequest[any]{Action: common.GetData, Data: options}
reqDataBytes, _ := cbor.Marshal(options)
req := common.HubRequest[cbor.RawMessage]{Action: common.GetData, Data: reqDataBytes}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()

View File

@@ -1,10 +0,0 @@
//go:build !testing
// +build !testing
package systems
// Background SMART fetching is enabled in production but disabled for tests (systems_test_helpers.go).
//
// The hub integration tests create/replace systems and clean up the test apps quickly.
// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB).
func backgroundSmartFetchEnabled() bool { return true }

View File

@@ -10,13 +10,6 @@ import (
entities "github.com/henrygd/beszel/internal/entities/system"
)
// The hub integration tests create/replace systems and cleanup the test apps quickly.
// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB).
//
// We keep the explicit SMART refresh endpoint / method available, but disable
// the automatic background fetch during tests.
func backgroundSmartFetchEnabled() bool { return false }
// TESTING ONLY: GetSystemCount returns the number of systems in the store
func (sm *SystemManager) GetSystemCount() int {
return sm.systems.Length()

View File

@@ -46,7 +46,6 @@ import type {
ChartTimes,
ContainerStatsRecord,
GPUData,
SystemDetailsRecord,
SystemInfo,
SystemRecord,
SystemStats,
@@ -167,8 +166,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
const userSettings = $userSettings.get()
const chartWrapRef = useRef<HTMLDivElement>(null)
const [details, setDetails] = useState<SystemDetailsRecord | null>(null)
const isPodman = useMemo(() => details?.podman ?? system.info?.p ?? false, [details, system.info?.p])
const [isPodman, setIsPodman] = useState(system.info?.p ?? false)
useEffect(() => {
return () => {
@@ -178,7 +176,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setDetails(null)
$containerFilter.set("")
}
}, [id])
@@ -206,22 +203,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
}
}, [system?.info?.v])
// fetch system details
useEffect(() => {
// if system.info.m exists, agent is old version without system details
if (!system.id || system.info?.m) {
return
}
pb.collection<SystemDetailsRecord>("system_details")
.getOne(system.id, {
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
headers: {
"Cache-Control": "public, max-age=60",
},
})
.then(setDetails)
}, [system.id])
// subscribe to realtime metrics if chart time is 1m
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
@@ -341,6 +322,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
})
}, [system, chartTime])
useEffect(() => {
setIsPodman(system.info?.p ?? false)
}, [system.info?.p])
/** Space for tooltip if more than 10 sensors and no containers table */
useEffect(() => {
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
@@ -413,7 +398,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<>
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
{/* system info */}
<InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} details={details} />
<InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} setIsPodman={setIsPodman} />
{/* <Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full h-11">

View File

@@ -11,13 +11,14 @@ import {
MonitorIcon,
Rows,
} from "lucide-react"
import { useMemo } from "react"
import { useEffect, useMemo, useState } from "react"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { pb } from "@/lib/api"
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
@@ -27,15 +28,44 @@ export default function InfoBar({
chartData,
grid,
setGrid,
details,
setIsPodman,
}: {
system: SystemRecord
chartData: ChartData
grid: boolean
setGrid: (grid: boolean) => void
details: SystemDetailsRecord | null
setIsPodman: (isPodman: boolean) => void
}) {
const { t } = useLingui()
const [details, setDetails] = useState<SystemDetailsRecord | null>(null)
// Fetch system_details on mount / when system changes
useEffect(() => {
let active = true
setDetails(null)
// skip fetching system details if agent is older version which includes details in Info struct
if (!system.id || system.info?.m) {
return
}
pb.collection<SystemDetailsRecord>("system_details")
.getOne(system.id, {
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
headers: {
"Cache-Control": "public, max-age=60",
},
})
.then((details) => {
if (active) {
setDetails(details)
setIsPodman(details.podman)
}
})
.catch(() => {})
return () => {
active = false
}
}, [system.id])
// values for system info bar - use details with fallback to system.info
const systemInfo = useMemo(() => {

View File

@@ -1,3 +1,9 @@
## 0.18.0
- Remove `la1`, `la5`, `la15` fields from `Info` struct in favor of `la` array.
- Remove `MB` bandwidth values in favor of bytes.
## 0.17.0
- Add quiet hours to silence alerts during specific time periods. (#265)