diff --git a/agent/agent.go b/agent/agent.go index 5dcd3b60..12a51ae3 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -17,6 +17,7 @@ import ( "github.com/gliderlabs/ssh" "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/deltatracker" + "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/host" gossh "golang.org/x/crypto/ssh" @@ -37,7 +38,8 @@ type Agent struct { netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers dockerManager *dockerManager // Manages Docker API requests sensorConfig *SensorConfig // Sensors config - systemInfo system.Info // Host system info + systemInfo system.Info // Host system info (dynamic) + systemDetails system.Details // Host system details (static, once-per-connection) gpuManager *GPUManager // Manages GPU data cache *systemDataCache // Cache for system stats based on cache time connectionManager *ConnectionManager // Channel to signal connection events @@ -97,8 +99,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { slog.Debug(beszel.Version) + // initialize docker manager + agent.dockerManager = newDockerManager() + // initialize system info - agent.initializeSystemInfo() + agent.refreshStaticInfo() // initialize connection manager agent.connectionManager = newConnectionManager(agent) @@ -112,9 +117,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { // initialize net io stats agent.initializeNetIoStats() - // initialize docker manager - agent.dockerManager = newDockerManager(agent) - agent.systemdManager, err = newSystemdManager() if err != nil { slog.Debug("Systemd", "err", err) @@ -133,7 +135,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { // if debugging, print stats if agent.debug { - slog.Debug("Stats", "data", agent.gatherStats(0)) + slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true})) } return agent, nil @@ -148,10 +150,11 @@ func GetEnv(key string) (value string, exists bool) { return os.LookupEnv(key) } -func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData { +func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData { a.Lock() defer a.Unlock() + cacheTimeMs := options.CacheTimeMs data, isCached := a.cache.Get(cacheTimeMs) if isCached { slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs) @@ -162,6 +165,12 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData { Stats: a.getSystemStats(cacheTimeMs), Info: a.systemInfo, } + + // Include static info only when requested + if options.IncludeDetails { + data.Details = &a.systemDetails + } + // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs) if a.dockerManager != nil { @@ -225,7 +234,7 @@ func (a *Agent) getFingerprint() string { // if no fingerprint is found, generate one fingerprint, err := host.HostID() if err != nil || fingerprint == "" { - fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel + fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel } // hash fingerprint diff --git a/agent/agent_cache_test.go b/agent/agent_cache_test.go index 2930563f..db7d5d78 100644 --- a/agent/agent_cache_test.go +++ b/agent/agent_cache_test.go @@ -22,7 +22,7 @@ func createTestCacheData() *system.CombinedData { DiskTotal: 100000, }, Info: system.Info{ - Hostname: "test-host", + AgentVersion: "0.12.0", }, Containers: []*container.Stats{ { @@ -128,7 +128,7 @@ func TestCacheMultipleIntervals(t *testing.T) { Mem: 16384, }, Info: system.Info{ - Hostname: "test-host-2", + AgentVersion: "0.12.0", }, Containers: []*container.Stats{}, } @@ -171,7 +171,7 @@ func TestCacheOverwrite(t *testing.T) { Mem: 32768, }, Info: system.Info{ - Hostname: "updated-host", + AgentVersion: "0.12.0", }, Containers: []*container.Stats{}, } diff --git a/agent/client.go b/agent/client.go index 48a965b9..3523117a 100644 --- a/agent/client.go +++ b/agent/client.go @@ -201,7 +201,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R if authRequest.NeedSysInfo { response.Name, _ = GetEnv("SYSTEM_NAME") - response.Hostname = client.agent.systemInfo.Hostname + response.Hostname = client.agent.systemDetails.Hostname serverAddr := client.agent.connectionManager.serverOptions.Addr _, response.Port, _ = net.SplitHostPort(serverAddr) } diff --git a/agent/docker.go b/agent/docker.go index fb306b0d..32b5818c 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -60,6 +60,7 @@ type dockerManager struct { decoder *json.Decoder // Reusable JSON decoder that reads from buf apiStats *container.ApiStats // Reusable API stats object excludeContainers []string // Patterns to exclude containers by name + usingPodman bool // Whether the Docker Engine API is running on Podman // Cache-time-aware tracking for CPU stats (similar to cpu.go) // Maps cache time intervals to container-specific CPU usage tracking @@ -478,7 +479,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) { } // Creates a new http client for Docker or Podman API -func newDockerManager(a *Agent) *dockerManager { +func newDockerManager() *dockerManager { dockerHost, exists := GetEnv("DOCKER_HOST") if exists { // return nil if set to empty string @@ -564,7 +565,7 @@ func newDockerManager(a *Agent) *dockerManager { // If using podman, return client if strings.Contains(dockerHost, "podman") { - a.systemInfo.Podman = true + manager.usingPodman = true manager.goodDockerVersion = true return manager } @@ -746,3 +747,23 @@ func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error { totalBytesRead += int(n) } } + +// GetHostInfo fetches the system info from Docker +func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) { + resp, err := dm.client.Get("http://localhost/info") + if err != nil { + return info, err + } + 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 + } + + return info, nil +} + +func (dm *dockerManager) IsPodman() bool { + return dm.usingPodman +} diff --git a/agent/docker_test.go b/agent/docker_test.go index bd0123c4..601e6b39 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -802,6 +802,24 @@ func TestNetworkRateCalculationFormula(t *testing.T) { } } +func TestGetHostInfo(t *testing.T) { + data, err := os.ReadFile("test-data/system_info.json") + require.NoError(t, err) + + var info container.HostInfo + err = json.Unmarshal(data, &info) + require.NoError(t, err) + + assert.Equal(t, "6.8.0-31-generic", info.KernelVersion) + assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem) + // assert.Equal(t, "24.04", info.OSVersion) + // assert.Equal(t, "linux", info.OSType) + // assert.Equal(t, "x86_64", info.Architecture) + assert.EqualValues(t, 4, info.NCPU) + assert.EqualValues(t, 2095882240, info.MemTotal) + // assert.Equal(t, "27.0.1", info.ServerVersion) +} + func TestDeltaTrackerCacheTimeIsolation(t *testing.T) { // Test that different cache times have separate DeltaTracker instances dm := &dockerManager{ diff --git a/agent/handlers.go b/agent/handlers.go index 931c4dfe..c6f8f9c3 100644 --- a/agent/handlers.go +++ b/agent/handlers.go @@ -94,7 +94,7 @@ func (h *GetDataHandler) Handle(hctx *HandlerContext) error { var options common.DataRequestOptions _ = cbor.Unmarshal(hctx.Request.Data, &options) - sysStats := hctx.Agent.gatherStats(options.CacheTimeMs) + sysStats := hctx.Agent.gatherStats(options) return hctx.SendResponse(sysStats, hctx.RequestID) } diff --git a/agent/server.go b/agent/server.go index c826d67f..b1c15abf 100644 --- a/agent/server.go +++ b/agent/server.go @@ -202,7 +202,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes // handleLegacyStats serves the legacy one-shot stats payload for older hubs func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error { - stats := a.gatherStats(60_000) + stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000}) return a.writeToSession(w, stats, hubVersion) } diff --git a/agent/server_test.go b/agent/server_test.go index bfee84e5..04a3965e 100644 --- a/agent/server_test.go +++ b/agent/server_test.go @@ -513,7 +513,7 @@ func TestWriteToSessionEncoding(t *testing.T) { err = json.Unmarshal([]byte(encodedData), &decodedJson) assert.Error(t, err, "Should not be valid JSON data") - assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname) + assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname) assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu) } else { // Should be JSON - try to decode as JSON @@ -526,7 +526,7 @@ func TestWriteToSessionEncoding(t *testing.T) { assert.Error(t, err, "Should not be valid CBOR data") // Verify the decoded JSON data matches our test data - assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname) + assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname) assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu) // Verify it looks like JSON (starts with '{' and contains readable field names) @@ -551,12 +551,8 @@ func createTestCombinedData() *system.CombinedData { DiskPct: 50.0, }, Info: system.Info{ - Hostname: "test-host", - Cores: 8, - CpuModel: "Test CPU Model", Uptime: 3600, AgentVersion: "0.12.0", - Os: system.Linux, }, Containers: []*container.Stats{ { diff --git a/agent/system.go b/agent/system.go index f845b956..3cd03144 100644 --- a/agent/system.go +++ b/agent/system.go @@ -2,6 +2,7 @@ package agent import ( "bufio" + "errors" "fmt" "log/slog" "os" @@ -11,6 +12,7 @@ import ( "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/battery" + "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/cpu" @@ -27,41 +29,70 @@ type prevDisk struct { } // Sets initial / non-changing values about the host system -func (a *Agent) initializeSystemInfo() { +func (a *Agent) refreshStaticInfo() { a.systemInfo.AgentVersion = beszel.Version - a.systemInfo.Hostname, _ = os.Hostname() + + // get host info from Docker if available + var hostInfo container.HostInfo + + if a.dockerManager != nil { + a.systemDetails.Podman = a.dockerManager.IsPodman() + hostInfo, _ = a.dockerManager.GetHostInfo() + } + + a.systemDetails.Hostname, _ = os.Hostname() platform, _, version, _ := host.PlatformInformation() if platform == "darwin" { - a.systemInfo.KernelVersion = version - a.systemInfo.Os = system.Darwin + a.systemDetails.Os = system.Darwin + a.systemDetails.OsName = fmt.Sprintf("macOS %s", version) } else if strings.Contains(platform, "indows") { - a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version) - a.systemInfo.Os = system.Windows + a.systemDetails.Os = system.Windows + a.systemDetails.OsName = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version) } else if platform == "freebsd" { - a.systemInfo.Os = system.Freebsd - a.systemInfo.KernelVersion = version + a.systemDetails.Os = system.Freebsd + a.systemDetails.Kernel = version + a.systemDetails.OsName = "FreeBSD" } else { - a.systemInfo.Os = system.Linux - } - - if a.systemInfo.KernelVersion == "" { - a.systemInfo.KernelVersion, _ = host.KernelVersion() + a.systemDetails.Os = system.Linux + a.systemDetails.OsName = hostInfo.OperatingSystem + a.systemDetails.Kernel = hostInfo.KernelVersion + if a.systemDetails.OsName == "" { + if prettyName, err := getLinuxOsPrettyName(); err == nil { + a.systemDetails.OsName = prettyName + } else { + a.systemDetails.OsName = platform + } + } + if a.systemDetails.Kernel == "" { + a.systemDetails.Kernel = version + } } // cpu model if info, err := cpu.Info(); err == nil && len(info) > 0 { - a.systemInfo.CpuModel = info[0].ModelName + a.systemDetails.CpuModel = info[0].ModelName } // cores / threads - a.systemInfo.Cores, _ = cpu.Counts(false) - if threads, err := cpu.Counts(true); err == nil { - if threads > 0 && threads < a.systemInfo.Cores { - // in lxc logical cores reflects container limits, so use that as cores if lower - a.systemInfo.Cores = threads - } else { - a.systemInfo.Threads = threads + a.systemDetails.Cores, _ = cpu.Counts(false) + a.systemDetails.Threads = hostInfo.NCPU + if a.systemDetails.Threads == 0 { + if threads, err := cpu.Counts(true); err == nil { + if threads > 0 && threads < a.systemDetails.Cores { + // in lxc logical cores reflects container limits, so use that as cores if lower + a.systemDetails.Cores = threads + } else { + a.systemDetails.Threads = threads + } + } + } + + // total memory + a.systemDetails.MemoryTotal = hostInfo.MemTotal + if a.systemDetails.MemoryTotal == 0 { + if v, err := mem.VirtualMemory(); err == nil { + a.systemDetails.MemoryTotal = v.Total } } @@ -195,21 +226,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { } } - // update base system info + // update system info a.systemInfo.ConnectionType = a.connectionManager.ConnectionType a.systemInfo.Cpu = systemStats.Cpu a.systemInfo.LoadAvg = systemStats.LoadAvg - // TODO: remove these in future release in favor of load avg array - a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0] - a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1] - a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2] a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.Battery = systemStats.Battery a.systemInfo.Uptime, _ = host.Uptime() - // TODO: in future release, remove MB bandwidth values in favor of bytes - a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv) a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] + a.systemInfo.Threads = a.systemDetails.Threads slog.Debug("sysinfo", "data", a.systemInfo) return systemStats @@ -240,3 +266,24 @@ func getARCSize() (uint64, error) { return 0, fmt.Errorf("failed to parse size field") } + +// getLinuxOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems +func getLinuxOsPrettyName() (string, error) { + file, err := os.Open("/etc/os-release") + if err != nil { + return "", err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok { + value := after + value = strings.Trim(value, `"`) + return value, nil + } + } + + return "", errors.New("pretty name not found") +} diff --git a/agent/test-data/system_info.json b/agent/test-data/system_info.json new file mode 100644 index 00000000..57a62bb7 --- /dev/null +++ b/agent/test-data/system_info.json @@ -0,0 +1,17 @@ +{ + "ID": "7TRN:IPZB:QYBB:VPBQ:UMPP:KARE:6ZNR:XE6T:7EWV:PKF4:ZOJD:TPYS", + "Containers": 14, + "ContainersRunning": 3, + "ContainersPaused": 1, + "ContainersStopped": 10, + "Images": 508, + "Driver": "overlay2", + "KernelVersion": "6.8.0-31-generic", + "OperatingSystem": "Ubuntu 24.04 LTS", + "OSVersion": "24.04", + "OSType": "linux", + "Architecture": "x86_64", + "NCPU": 4, + "MemTotal": 2095882240, + "ServerVersion": "27.0.1" +} diff --git a/beszel.go b/beszel.go index c19750d7..d71abac4 100644 --- a/beszel.go +++ b/beszel.go @@ -6,7 +6,7 @@ import "github.com/blang/semver" const ( // Version is the current version of the application. - Version = "0.17.0" + Version = "0.18.0-beta.1" // AppName is the name of the application. AppName = "beszel" ) diff --git a/internal/alerts/alerts_battery_test.go b/internal/alerts/alerts_battery_test.go index e3824d4d..3baeb417 100644 --- a/internal/alerts/alerts_battery_test.go +++ b/internal/alerts/alerts_battery_test.go @@ -60,10 +60,10 @@ func TestBatteryAlertLogic(t *testing.T) { combinedDataHigh := &system.CombinedData{ Stats: statsHigh, Info: system.Info{ - Hostname: "test-host", - Cpu: 10, - MemPct: 30, - DiskPct: 40, + AgentVersion: "0.12.0", + Cpu: 10, + MemPct: 30, + DiskPct: 40, }, } @@ -100,10 +100,10 @@ func TestBatteryAlertLogic(t *testing.T) { combinedDataLow := &system.CombinedData{ Stats: statsLow, Info: system.Info{ - Hostname: "test-host", - Cpu: 10, - MemPct: 30, - DiskPct: 40, + AgentVersion: "0.12.0", + Cpu: 10, + MemPct: 30, + DiskPct: 40, }, } @@ -142,10 +142,10 @@ func TestBatteryAlertLogic(t *testing.T) { combinedDataRecovered := &system.CombinedData{ Stats: statsRecovered, Info: system.Info{ - Hostname: "test-host", - Cpu: 10, - MemPct: 30, - DiskPct: 40, + AgentVersion: "0.12.0", + Cpu: 10, + MemPct: 30, + DiskPct: 40, }, } @@ -198,10 +198,10 @@ func TestBatteryAlertNoBattery(t *testing.T) { combinedData := &system.CombinedData{ Stats: statsNoBattery, Info: system.Info{ - Hostname: "test-host", - Cpu: 10, - MemPct: 30, - DiskPct: 40, + AgentVersion: "0.12.0", + Cpu: 10, + MemPct: 30, + DiskPct: 40, }, } @@ -294,10 +294,10 @@ func TestBatteryAlertAveragedSamples(t *testing.T) { Battery: [2]uint8{15, 1}, }, Info: system.Info{ - Hostname: "test-host", - Cpu: 10, - MemPct: 30, - DiskPct: 40, + AgentVersion: "0.12.0", + Cpu: 10, + MemPct: 30, + DiskPct: 40, }, } @@ -360,10 +360,10 @@ func TestBatteryAlertAveragedSamples(t *testing.T) { Battery: [2]uint8{50, 1}, }, Info: system.Info{ - Hostname: "test-host", - Cpu: 10, - MemPct: 30, - DiskPct: 40, + AgentVersion: "0.12.0", + Cpu: 10, + MemPct: 30, + DiskPct: 40, }, } diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go index 290e0dbb..08237362 100644 --- a/internal/common/common-ws.go +++ b/internal/common/common-ws.go @@ -58,8 +58,8 @@ type FingerprintResponse struct { } type DataRequestOptions struct { - CacheTimeMs uint16 `cbor:"0,keyasint"` - // ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"` + CacheTimeMs uint16 `cbor:"0,keyasint"` + IncludeDetails bool `cbor:"1,keyasint"` } type ContainerLogsRequest struct { diff --git a/internal/entities/container/container.go b/internal/entities/container/container.go index ae9df4e6..9b3cc719 100644 --- a/internal/entities/container/container.go +++ b/internal/entities/container/container.go @@ -34,6 +34,17 @@ type ApiStats struct { MemoryStats MemoryStats `json:"memory_stats"` } +// 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 { cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer systemDelta := s.CPUStats.SystemUsage - prevCpuSystem diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 0649666f..c64b036d 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -123,27 +123,29 @@ const ( ConnectionTypeWebSocket ) +// Core system data that is needed in All Systems table type Info struct { - Hostname string `json:"h" cbor:"0,keyasint"` - KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` - Cores int `json:"c" cbor:"2,keyasint"` + 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" cbor:"4,keyasint"` 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"` GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"` - Os Os `json:"os" cbor:"14,keyasint"` - LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` - LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` - LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` + 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 BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` - // TODO: remove load fields in future release in favor of load avg array + LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"` ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` @@ -151,10 +153,24 @@ type Info struct { Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state] } +// Data that does not change during process lifetime and is not needed in All Systems table +type Details struct { + Hostname string `cbor:"0,keyasint"` + Kernel string `cbor:"1,keyasint,omitempty"` + Cores int `cbor:"2,keyasint"` + Threads int `cbor:"3,keyasint,omitempty"` + CpuModel string `cbor:"4,keyasint"` + Os Os `cbor:"5,keyasint"` + OsName string `cbor:"6,keyasint"` + Podman bool `cbor:"8,keyasint,omitempty"` + MemoryTotal uint64 `cbor:"9,keyasint"` +} + // Final data structure to return to the hub type CombinedData struct { Stats Stats `json:"stats" cbor:"0,keyasint"` Info Info `json:"info" cbor:"1,keyasint"` Containers []*container.Stats `json:"container" cbor:"2,keyasint"` SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"` + Details *Details `cbor:"4,keyasint,omitempty"` } diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index f18704aa..e64f174c 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "hash/fnv" + "log/slog" "math/rand" "net" "strings" @@ -42,6 +43,7 @@ type System struct { agentVersion semver.Version // Agent version updateTicker *time.Ticker // Ticker for updating the system smartOnce sync.Once // Once for fetching and saving smart devices + detailsOnce sync.Once // Once for fetching and saving static system details } func (sm *SystemManager) NewSystem(systemId string) *System { @@ -114,7 +116,14 @@ func (sys *System) update() error { sys.handlePaused() return nil } - data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)}) + options := common.DataRequestOptions{ + CacheTimeMs: uint16(interval), + } + // fetch system details only on the first update + sys.detailsOnce.Do(func() { + options.IncludeDetails = true + }) + data, err := sys.fetchDataFromAgent(options) if err == nil { _, err = sys.createRecords(data) } @@ -142,6 +151,12 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error } hub := sys.manager.hub err = hub.RunInTransaction(func(txApp core.App) error { + if data.Details != nil { + slog.Info("Static info", "data", data.Details) + if err := createStaticInfoRecord(txApp, data.Details, sys.Id); err != nil { + return err + } + } // add system_stats and container_stats records systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats") if err != nil { @@ -203,6 +218,29 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error return systemRecord, err } +func createStaticInfoRecord(app core.App, data *system.Details, systemId string) error { + record, err := app.FindRecordById("system_details", systemId) + if err != nil { + collection, err := app.FindCollectionByNameOrId("system_details") + if err != nil { + return err + } + record = core.NewRecord(collection) + record.Set("id", systemId) + } + record.Set("system", systemId) + record.Set("hostname", data.Hostname) + record.Set("kernel", data.Kernel) + record.Set("cores", data.Cores) + record.Set("threads", data.Threads) + record.Set("cpu", data.CpuModel) + record.Set("os", data.Os) + record.Set("os_name", data.OsName) + record.Set("memory", data.MemoryTotal) + record.Set("podman", data.Podman) + return app.SaveNoValidate(record) +} + func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error { if len(data) == 0 { return nil diff --git a/internal/hub/systems/systems_test.go b/internal/hub/systems/systems_test.go index 5f362b23..db7e6bb3 100644 --- a/internal/hub/systems/systems_test.go +++ b/internal/hub/systems/systems_test.go @@ -266,18 +266,20 @@ func testOld(t *testing.T, hub *tests.TestHub) { // Create test system data testData := &system.CombinedData{ + Details: &system.Details{ + Hostname: "data-test.example.com", + Kernel: "5.15.0-generic", + Cores: 4, + Threads: 8, + CpuModel: "Test CPU", + }, Info: system.Info{ - Hostname: "data-test.example.com", - KernelVersion: "5.15.0-generic", - Cores: 4, - Threads: 8, - CpuModel: "Test CPU", - Uptime: 3600, - Cpu: 25.5, - MemPct: 40.2, - DiskPct: 60.0, - Bandwidth: 100.0, - AgentVersion: "1.0.0", + Uptime: 3600, + Cpu: 25.5, + MemPct: 40.2, + DiskPct: 60.0, + Bandwidth: 100.0, + AgentVersion: "1.0.0", }, Stats: system.Stats{ Cpu: 25.5, diff --git a/internal/migrations/0_collections_snapshot_0_17_1_dev_0.go b/internal/migrations/0_collections_snapshot_0_18_0_dev_0.go similarity index 90% rename from internal/migrations/0_collections_snapshot_0_17_1_dev_0.go rename to internal/migrations/0_collections_snapshot_0_18_0_dev_0.go index 69fcb86c..f416dc4b 100644 --- a/internal/migrations/0_collections_snapshot_0_17_1_dev_0.go +++ b/internal/migrations/0_collections_snapshot_0_18_0_dev_0.go @@ -1439,6 +1439,172 @@ func init() { "type": "base", "updateRule": null, "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id" + }, + { + "createRule": "", + "deleteRule": "", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "2hz5ncl8tizk5nx", + "hidden": false, + "id": "relation3377271179", + "maxSelect": 1, + "minSelect": 0, + "name": "system", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3847340049", + "max": 0, + "min": 0, + "name": "hostname", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1789936913", + "max": null, + "min": null, + "name": "os", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2818598173", + "max": 0, + "min": 0, + "name": "os_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1574083243", + "max": 0, + "min": 0, + "name": "kernel", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3128971310", + "max": 0, + "min": 0, + "name": "cpu", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number4245036687", + "max": null, + "min": null, + "name": "cores", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1871592925", + "max": null, + "min": null, + "name": "threads", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3933025333", + "max": null, + "min": null, + "name": "memory", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool2200265312", + "name": "podman", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3116237454", + "indexes": [ + "CREATE UNIQUE INDEX ` + "`" + `idx_5d1egp3jVU` + "`" + ` ON ` + "`" + `system_details` + "`" + ` (` + "`" + `system` + "`" + `)" + ], + "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", + "name": "system_details", + "system": false, + "type": "base", + "updateRule": "", + "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id" } ]` diff --git a/internal/site/package.json b/internal/site/package.json index 6acdde7d..4a0f5aae 100644 --- a/internal/site/package.json +++ b/internal/site/package.json @@ -1,7 +1,7 @@ { "name": "beszel", "private": true, - "version": "0.17.0", + "version": "0.18.0-beta.1", "type": "module", "scripts": { "dev": "vite --host", diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 8feec020..de71d5a3 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -3,15 +3,7 @@ import { Trans, useLingui } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import { timeTicks } from "d3-time" -import { - ChevronRightSquareIcon, - ClockArrowUp, - CpuIcon, - GlobeIcon, - LayoutGridIcon, - MonitorIcon, - XIcon, -} from "lucide-react" +import { XIcon } from "lucide-react" import { subscribeKeys } from "nanostores" import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart" @@ -24,7 +16,7 @@ import MemChart from "@/components/charts/mem-chart" import SwapChart from "@/components/charts/swap-chart" import TemperatureChart from "@/components/charts/temperature-chart" import { getPbTimestamp, pb } from "@/lib/api" -import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums" +import { ChartType, Os, SystemStatus, Unit } from "@/lib/enums" import { batteryStateTranslations } from "@/lib/i18n" import { $allSystemsById, @@ -44,8 +36,6 @@ import { compareSemVer, decimalString, formatBytes, - secondsToString, - getHostDisplayValue, listen, parseSemVer, toFixedFloat, @@ -61,20 +51,18 @@ import type { SystemStats, SystemStatsRecord, } from "@/types" -import ChartTimeSelect from "../charts/chart-time-select" import { $router, navigate } from "../router" import Spinner from "../spinner" import { Button } from "../ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card" -import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons" +import { ChartAverage, ChartMax } from "../ui/icons" import { Input } from "../ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" -import { Separator } from "../ui/separator" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip" import NetworkSheet from "./system/network-sheet" import CpuCoresSheet from "./system/cpu-sheet" import LineChartDefault from "../charts/line-chart" import { pinnedAxisDomain } from "../ui/chart" +import InfoBar from "./system/info-bar" type ChartTimeData = { time: number @@ -154,8 +142,8 @@ async function getStats( }) } -function dockerOrPodman(str: string, system: SystemRecord): string { - if (system.info.p) { +function dockerOrPodman(str: string, isPodman: boolean): string { + if (isPodman) { return str.replace("docker", "podman").replace("Docker", "Podman") } return str @@ -178,6 +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(null) + const [isPodman, setIsPodman] = useState(system.info?.p ?? false) useEffect(() => { return () => { @@ -217,7 +206,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { // subscribe to realtime metrics if chart time is 1m // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary useEffect(() => { - let unsub = () => { } + let unsub = () => {} if (!system.id || chartTime !== "1m") { return } @@ -333,62 +322,9 @@ export default memo(function SystemDetail({ id }: { id: string }) { }) }, [system, chartTime]) - // values for system info bar - const systemInfo = useMemo(() => { - if (!system.info) { - return [] - } - - const osInfo = { - [Os.Linux]: { - Icon: TuxIcon, - value: system.info.k, - label: t({ comment: "Linux kernel", message: "Kernel" }), - }, - [Os.Darwin]: { - Icon: AppleIcon, - value: `macOS ${system.info.k}`, - }, - [Os.Windows]: { - Icon: WindowsIcon, - value: system.info.k, - }, - [Os.FreeBSD]: { - Icon: FreeBsdIcon, - value: system.info.k, - }, - } - let uptime: string - if (system.info.u < 3600) { - uptime = secondsToString(system.info.u, "minute") - } else if (system.info.u < 360000) { - uptime = secondsToString(system.info.u, "hour") - } else { - uptime = secondsToString(system.info.u, "day") - } - return [ - { value: getHostDisplayValue(system), Icon: GlobeIcon }, - { - value: system.info.h, - Icon: MonitorIcon, - label: "Hostname", - // hide if hostname is same as host or name - hide: system.info.h === system.host || system.info.h === system.name, - }, - { value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u }, - osInfo[system.info.os ?? Os.Linux], - { - value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`, - Icon: CpuIcon, - hide: !system.info.m, - }, - ] as { - value: string | number | undefined - label?: string - Icon: React.ElementType - hide?: boolean - }[] - }, [system, t]) + useEffect(() => { + setIsPodman(system.info?.p ?? false) + }, [system.info?.p]) /** Space for tooltip if more than 10 sensors and no containers table */ useEffect(() => { @@ -458,113 +394,11 @@ export default memo(function SystemDetail({ id }: { id: string }) { const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined) const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined) - let translatedStatus: string = system.status - if (system.status === SystemStatus.Up) { - translatedStatus = t({ message: "Up", comment: "Context: System is up" }) - } else if (system.status === SystemStatus.Down) { - translatedStatus = t({ message: "Down", comment: "Context: System is down" }) - } - return ( <>
{/* system info */} - -
-
-

{system.name}

-
- - - -
- - {system.status === SystemStatus.Up && ( - - )} - - - {translatedStatus} -
-
- {system.info.ct && ( - -
- {system.info.ct === ConnectionType.WebSocket ? ( - - ) : ( - - )} - {connectionTypeLabels[system.info.ct as ConnectionType]} -
-
- )} -
-
- - {systemInfo.map(({ value, label, Icon, hide }) => { - if (hide || !value) { - return null - } - const content = ( -
- {value} -
- ) - return ( -
- - {label ? ( - - - {content} - {label} - - - ) : ( - content - )} -
- ) - })} -
-
-
- - - - - - - {t`Toggle grid`} - - -
-
-
- + {/* @@ -576,7 +410,6 @@ export default memo(function SystemDetail({ id }: { id: string }) { */} - {/* main charts */}
@@ -639,8 +472,8 @@ export default memo(function SystemDetail({ id }: { id: string }) { +
{ if (showMax) { - return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024 + return ( + stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024 + ) } return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024 }, @@ -1003,9 +835,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
)} - {compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && ( - - )} + {compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && } {containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && ( @@ -1061,13 +891,10 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt return () => clearTimeout(handle) }, [inputValue, storeValue, store]) - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value - setInputValue(value) - }, - [] - ) + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + }, []) const handleClear = useCallback(() => { setInputValue("") @@ -1194,4 +1021,4 @@ function LazySystemdTable({ systemId }: { systemId: string }) { {isIntersecting && }
) -} \ No newline at end of file +} diff --git a/internal/site/src/components/routes/system/info-bar.tsx b/internal/site/src/components/routes/system/info-bar.tsx new file mode 100644 index 00000000..0b6c0a2c --- /dev/null +++ b/internal/site/src/components/routes/system/info-bar.tsx @@ -0,0 +1,249 @@ +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 { SystemStatus, ConnectionType, connectionTypeLabels, Os } from "@/lib/enums" +import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" +import { + AppleIcon, + ChevronRightSquareIcon, + ClockArrowUp, + CpuIcon, + GlobeIcon, + LayoutGridIcon, + MonitorIcon, + Rows, + MemoryStickIcon, +} from "lucide-react" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types" +import { useEffect, useMemo, useState } from "react" +import { useLingui } from "@lingui/react/macro" +import { pb } from "@/lib/api" + +export default function InfoBar({ + system, + chartData, + grid, + setGrid, + setIsPodman, +}: { + system: SystemRecord + chartData: ChartData + grid: boolean + setGrid: (grid: boolean) => void + setIsPodman: (isPodman: boolean) => void +}) { + const { t } = useLingui() + const [details, setDetails] = useState(null) + + // Fetch system_details on mount / when system changes + useEffect(() => { + // skip fetching system details if agent is older version which includes details in Info struct + if (!system.id || system.info?.m) { + return setDetails(null) + } + pb.collection("system_details") + .getOne(system.id, { + fields: "hostname,kernel,cores,threads,cpu,os,os_name,memory,podman", + headers: { + "Cache-Control": "public, max-age=60", + }, + }) + .then((details) => { + setDetails(details) + setIsPodman(details.podman) + }) + .catch(() => setDetails(null)) + }, [system.id]) + + // values for system info bar - use details with fallback to system.info + const systemInfo = useMemo(() => { + if (!system.info) { + return [] + } + + // Use details if available, otherwise fall back to system.info + const hostname = details?.hostname ?? system.info.h + const kernel = details?.kernel ?? system.info.k + const cores = details?.cores ?? system.info.c + const threads = details?.threads ?? system.info.t + const cpuModel = details?.cpu ?? system.info.m + const os = details?.os ?? system.info.os ?? Os.Linux + const osName = details?.os_name + const memory = details?.memory + + const osInfo = { + [Os.Linux]: { + Icon: TuxIcon, + // show kernel in tooltip if os name is available, otherwise show the kernel + value: osName || kernel, + label: osName ? kernel : undefined, + // label: t({ comment: "Linux kernel", message: "Kernel" }), + }, + [Os.Darwin]: { + Icon: AppleIcon, + value: osName || `macOS ${kernel}`, + }, + [Os.Windows]: { + Icon: WindowsIcon, + value: osName || kernel, + }, + [Os.FreeBSD]: { + Icon: FreeBsdIcon, + value: osName || kernel, + label: osName ? kernel : undefined, + }, + } + + let uptime: string + if (system.info.u < 3600) { + uptime = secondsToString(system.info.u, "minute") + } else if (system.info.u < 360000) { + uptime = secondsToString(system.info.u, "hour") + } else { + uptime = secondsToString(system.info.u, "day") + } + const info = [ + { value: getHostDisplayValue(system), Icon: GlobeIcon }, + { + value: hostname, + Icon: MonitorIcon, + label: "Hostname", + // hide if hostname is same as host or name + hide: hostname === system.host || hostname === system.name, + }, + { value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u }, + osInfo[os], + ] as { + value: string | number | undefined + label?: string + Icon: React.ElementType + hide?: boolean + }[] + + if (memory) { + const memValue = formatBytes(memory, false, undefined, false) + info.push({ + value: `${toFixedFloat(memValue.value, memValue.value >= 10 ? 1 : 2)} ${memValue.unit}`, + Icon: MemoryStickIcon, + hide: !memory, + label: t`Memory`, + }) + } + + info.push({ + value: `${cpuModel} (${cores}c${threads ? `/${threads}t` : ""})`, + Icon: CpuIcon, + hide: !cpuModel, + }) + + return info + }, [system, details, t]) + + let translatedStatus: string = system.status + if (system.status === SystemStatus.Up) { + translatedStatus = t({ message: "Up", comment: "Context: System is up" }) + } else if (system.status === SystemStatus.Down) { + translatedStatus = t({ message: "Down", comment: "Context: System is down" }) + } + + return ( + +
+
+

{system.name}

+
+ + + +
+ + {system.status === SystemStatus.Up && ( + + )} + + + {translatedStatus} +
+
+ {system.info.ct && ( + +
+ {system.info.ct === ConnectionType.WebSocket ? ( + + ) : ( + + )} + {connectionTypeLabels[system.info.ct as ConnectionType]} +
+
+ )} +
+
+ + {systemInfo.map(({ value, label, Icon, hide }) => { + if (hide || !value) { + return null + } + const content = ( +
+ {value} +
+ ) + return ( +
+ + {label ? ( + + + {content} + {label} + + + ) : ( + content + )} +
+ ) + })} +
+
+
+ + + + + + + {t`Toggle grid`} + + +
+
+
+ ) +} diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 682e6960..20363a29 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -380,6 +380,19 @@ export interface SmartAttribute { wf?: string } +export interface SystemDetailsRecord extends RecordModel { + system: string + hostname: string + kernel: string + cores: number + threads: number + cpu: string + os: Os + os_name: string + memory: number + podman: boolean +} + export interface SmartDeviceRecord extends RecordModel { id: string system: string diff --git a/supplemental/CHANGELOG.md b/supplemental/CHANGELOG.md index 9890292b..c535d250 100644 --- a/supplemental/CHANGELOG.md +++ b/supplemental/CHANGELOG.md @@ -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)