From 86ab0fae8b7b2d87251a90a4fa56138d17cca553 Mon Sep 17 00:00:00 2001 From: Vito Cappello Date: Fri, 25 Sep 2026 17:43:57 -0400 Subject: [PATCH] feat: monitor connected Wi-Fi signal per interface (#2367) --- agent/system.go | 4 + agent/wifi/README.md | 46 ++++++ agent/wifi/wifi.go | 40 ++++++ agent/wifi/wifi_darwin.go | 43 ++++++ agent/wifi/wifi_linux.go | 78 ++++++++++ agent/wifi/wifi_linux_test.go | 133 ++++++++++++++++++ agent/wifi/wifi_test.go | 35 +++++ agent/wifi/wifi_unsupported.go | 11 ++ agent/wifi/wifi_windows.go | 99 +++++++++++++ go.mod | 5 + go.sum | 8 ++ internal/entities/system/system.go | 11 ++ internal/entities/system/wifi_test.go | 37 +++++ internal/hub/systems/system_wifi_test.go | 28 ++++ internal/hub/transport/transport.go | 8 ++ internal/hub/transport/wifi_test.go | 35 +++++ internal/records/records.go | 21 +++ internal/records/records_wifi_test.go | 26 ++++ .../site/src/components/routes/system.tsx | 3 + .../routes/system/charts/wifi-chart.tsx | 47 +++++++ .../systems-table/systems-table-columns.tsx | 41 ++++++ internal/site/src/lib/wifi.test.ts | 28 ++++ internal/site/src/lib/wifi.ts | 20 +++ internal/site/src/types.d.ts | 7 + 24 files changed, 814 insertions(+) create mode 100644 agent/wifi/README.md create mode 100644 agent/wifi/wifi.go create mode 100644 agent/wifi/wifi_darwin.go create mode 100644 agent/wifi/wifi_linux.go create mode 100644 agent/wifi/wifi_linux_test.go create mode 100644 agent/wifi/wifi_test.go create mode 100644 agent/wifi/wifi_unsupported.go create mode 100644 agent/wifi/wifi_windows.go create mode 100644 internal/entities/system/wifi_test.go create mode 100644 internal/hub/systems/system_wifi_test.go create mode 100644 internal/hub/transport/wifi_test.go create mode 100644 internal/records/records_wifi_test.go create mode 100644 internal/site/src/components/routes/system/charts/wifi-chart.tsx create mode 100644 internal/site/src/lib/wifi.test.ts create mode 100644 internal/site/src/lib/wifi.ts diff --git a/agent/system.go b/agent/system.go index f31e13a39..66d7fa747 100644 --- a/agent/system.go +++ b/agent/system.go @@ -14,6 +14,7 @@ import ( "github.com/henrygd/beszel/agent/battery" "github.com/henrygd/beszel/agent/btrfs" "github.com/henrygd/beszel/agent/utils" + "github.com/henrygd/beszel/agent/wifi" "github.com/henrygd/beszel/agent/zfs" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" @@ -267,6 +268,8 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { } } + systemStats.WiFi = wifi.Collect() + // update system info a.systemInfo.ConnectionType = a.connectionManager.ConnectionType a.systemInfo.Cpu = systemStats.Cpu @@ -274,6 +277,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.Battery = systemStats.Battery + a.systemInfo.WiFi = systemStats.WiFi a.systemInfo.Uptime, _ = getUptime() a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.Threads = a.systemDetails.Threads diff --git a/agent/wifi/README.md b/agent/wifi/README.md new file mode 100644 index 000000000..1cdc0350a --- /dev/null +++ b/agent/wifi/README.md @@ -0,0 +1,46 @@ +# Connected Wi-Fi signal + +Each fresh stats poll reports a snapshot of connected station interfaces in +`stats.wifi` and `info.wifi`. Map keys identify interfaces, not networks. `s` is +optional SSID metadata; `r` is nullable native RSSI in dBm. Quality percentages +are never converted to dBm. An associated interface without an accessible RSSI +still appears with an unavailable signal. No scans or network changes occur. + +The hub panel gates exclusively on current `systems.info.wifi` and system `up` +status, independently of the selected historical period. Empty/null snapshots +clear it. Historical averages use only available readings per interface; gaps +are not zero signal. Interface colors and keys remain stable on reconnect. +SSID in an aggregate is the latest observed metadata, not a separate series. + +## Platforms + +- Linux: native nl80211 through `github.com/mdlayher/wifi`, compiled into the + static agent, including scratch and all other agent images. No `iw`, shared + libraries, extra capabilities, or external helper required. Host network + namespace access (Docker `network_mode: host`) is necessary to see host Wi-Fi. + Only managed station interfaces with explicit associated BSS status appear. + Kernel BSS cache reads do not trigger scans; station statistics supply native + RSSI matched to the associated AP. Denied/missing station statistics retain + association with unavailable RSSI, never substitute stale scan-cache signal. + Missing nl80211/driver support or denied association reads yield no readings. + A single two-second socket deadline bounds enumeration and interface queries + after opening the client. The library's initial nl80211 family discovery is + synchronous and does not expose a deadline. +- macOS: system `osascript` uses public CoreWLAN via JXA. Station mode proves + association; RSSI and optional SSID are read independently. No private airport + binary, elevated command or compiled helper required. Privacy settings may + redact SSIDs. The subprocess has a two-second deadline. +- Windows: native WLAN API, interface GUID identity, connected interface state, + optional current-connection SSID and native RSSI query. No localized `netsh` + parsing. Missing WLAN service/API yields no readings; denied SSID/RSSI query + leaves a connected interface with missing metadata/signal. Non-UTF-8 raw + SSIDs are omitted so they cannot invalidate CBOR text in the agent response. + Native synchronous WLAN calls cannot be interrupted by the Go deadline. +- FreeBSD and other platforms: unsupported, empty snapshot. No approximation + from ifconfig quality and no stale data retained. + +Collectors retry each fresh poll, allowing interfaces and capabilities to appear +without an agent restart. Standard agent response caching still applies. Existing +hub record JSON storage requires no database schema migration. Older agents +without the field keep the panel hidden. Native macOS/Windows runtime checks and +real adapter testing are still required; cross compilation is not hardware proof. diff --git a/agent/wifi/wifi.go b/agent/wifi/wifi.go new file mode 100644 index 000000000..f45a7a064 --- /dev/null +++ b/agent/wifi/wifi.go @@ -0,0 +1,40 @@ +// Package wifi collects only currently associated station interfaces. Collection +// failures are empty snapshots, never cached connected state. +package wifi + +import ( + "context" + "os" + "os/exec" + "time" + "unicode/utf8" + + "github.com/henrygd/beszel/internal/entities/system" +) + +type commandRunner func(context.Context, string, ...string) ([]byte, error) + +func run(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + cmd.WaitDelay = 100 * time.Millisecond + return cmd.Output() +} + +// validSSID omits non-UTF-8 SSIDs: 802.11 permits arbitrary octets, but CBOR +// text strings require UTF-8. Metadata must never invalidate the whole response. +func validSSID(ssid string) string { + if !utf8.ValidString(ssid) { + return "" + } + return ssid +} + +// Collect uses a single deadline across interface queries where supported. +// Unsupported platforms and denied association access produce no readings; +// later polls retry. +func Collect() map[string]system.WiFi { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return collect(ctx) +} diff --git a/agent/wifi/wifi_darwin.go b/agent/wifi/wifi_darwin.go new file mode 100644 index 000000000..40f07863f --- /dev/null +++ b/agent/wifi/wifi_darwin.go @@ -0,0 +1,43 @@ +//go:build darwin + +package wifi + +import ( + "context" + "encoding/json" + + "github.com/henrygd/beszel/internal/entities/system" +) + +// JXA exposes the system CoreWLAN framework without cgo, private airport tools, +// sudo, or scanning nearby networks. SSID can be redacted by macOS privacy rules. +const coreWLANScript = `ObjC.import('CoreWLAN'); +var result = {}; +var interfaces = $.CWWiFiClient.sharedWiFiClient.interfaces; +if (interfaces) { + for (var i = 0; i < interfaces.count; i++) { + var iface = interfaces.objectAtIndex(i); + if (!iface.powerOn || Number(iface.interfaceMode) !== 1) continue; + var name = ObjC.unwrap(iface.interfaceName); + if (!name) continue; + var reading = {}; + var ssid = ObjC.unwrap(iface.ssid); + if (ssid) reading.s = ssid; + var signal = Number(iface.rssiValue); + if (signal >= -150 && signal < 0) reading.r = signal; + result[name] = reading; + } +} +JSON.stringify(result);` + +func collect(ctx context.Context) map[string]system.WiFi { + output, err := run(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript) + if err != nil { + return nil + } + var result map[string]system.WiFi + if json.Unmarshal(output, &result) != nil { + return nil + } + return result +} diff --git a/agent/wifi/wifi_linux.go b/agent/wifi/wifi_linux.go new file mode 100644 index 000000000..fa71e0dcc --- /dev/null +++ b/agent/wifi/wifi_linux.go @@ -0,0 +1,78 @@ +//go:build linux + +package wifi + +import ( + "bytes" + "context" + "time" + + "github.com/henrygd/beszel/internal/entities/system" + native "github.com/mdlayher/wifi" +) + +type linuxClient interface { + Interfaces() ([]*native.Interface, error) + BSS(*native.Interface) (*native.BSS, error) + StationInfo(*native.Interface) ([]*native.StationInfo, error) + SetDeadline(time.Time) error + Close() error +} + +func collect(ctx context.Context) map[string]system.WiFi { + client, err := native.New() + if err != nil { + return nil + } + defer client.Close() + return collectLinux(ctx, client) +} + +func collectLinux(ctx context.Context, client linuxClient) map[string]system.WiFi { + result := make(map[string]system.WiFi) + if ctx.Err() != nil { + return result + } + if deadline, ok := ctx.Deadline(); ok { + if client.SetDeadline(deadline) != nil { + return result + } + } + interfaces, err := client.Interfaces() + if err != nil { + return result + } + for _, iface := range interfaces { + if ctx.Err() != nil { + break + } + if iface == nil || iface.Type != native.InterfaceTypeStation || iface.Name == "" { + continue + } + // GET_SCAN reads the kernel's BSS cache, without triggering a scan. + // Only the explicit associated status proves a current connection. + bss, err := client.BSS(iface) + if err != nil || bss == nil || bss.Status != native.BSSStatusAssociated { + continue + } + reading := system.WiFi{SSID: validSSID(bss.SSID)} + // Station statistics may require permissions unavailable in default + // containers. Keep association even when RSSI cannot be read. Do not + // substitute cached scan signal, which may be arbitrarily old. + stations, err := client.StationInfo(iface) + if err == nil { + for _, station := range stations { + if station == nil || len(bss.BSSID) == 0 || !bytes.Equal(station.HardwareAddr, bss.BSSID) { + continue + } + signal := float64(station.Signal) + if signal >= -150 && signal < 0 { + reading.Signal = &signal + } + break + } + } + result[iface.Name] = reading + } + return result +} diff --git a/agent/wifi/wifi_linux_test.go b/agent/wifi/wifi_linux_test.go new file mode 100644 index 000000000..9a83042c2 --- /dev/null +++ b/agent/wifi/wifi_linux_test.go @@ -0,0 +1,133 @@ +//go:build linux + +package wifi + +import ( + "context" + "errors" + "net" + "testing" + "time" + + native "github.com/mdlayher/wifi" +) + +type fakeLinuxClient struct { + interfaces []*native.Interface + bss map[string]*native.BSS + stations map[string][]*native.StationInfo + interfacesErr, bssErr, stationErr, deadlineErr error + deadline time.Time + stationCalls int +} + +func (f *fakeLinuxClient) Interfaces() ([]*native.Interface, error) { + return f.interfaces, f.interfacesErr +} +func (f *fakeLinuxClient) BSS(i *native.Interface) (*native.BSS, error) { + return f.bss[i.Name], f.bssErr +} +func (f *fakeLinuxClient) StationInfo(i *native.Interface) ([]*native.StationInfo, error) { + f.stationCalls++ + return f.stations[i.Name], f.stationErr +} +func (f *fakeLinuxClient) SetDeadline(d time.Time) error { f.deadline = d; return f.deadlineErr } +func (f *fakeLinuxClient) Close() error { return nil } + +func connectedClient() *fakeLinuxClient { + mac := net.HardwareAddr{1, 2, 3, 4, 5, 6} + return &fakeLinuxClient{ + interfaces: []*native.Interface{{Name: "wlan0", Type: native.InterfaceTypeStation}}, + bss: map[string]*native.BSS{"wlan0": {Status: native.BSSStatusAssociated, SSID: "home", BSSID: mac}}, + stations: map[string][]*native.StationInfo{"wlan0": {{HardwareAddr: mac, Signal: -52}}}, + } +} + +func TestLinuxSnapshots(t *testing.T) { + for _, tc := range []struct { + name string + modify func(*fakeLinuxClient) + want int + wantSignal bool + }{ + {"connected", func(f *fakeLinuxClient) {}, 1, true}, + {"multiple", func(f *fakeLinuxClient) { + f.interfaces = append(f.interfaces, &native.Interface{Name: "wlan1", Type: native.InterfaceTypeStation}) + f.bss["wlan1"] = f.bss["wlan0"] + }, 2, true}, + {"unsupported", func(f *fakeLinuxClient) { f.interfacesErr = errors.New("unsupported") }, 0, false}, + {"association denied", func(f *fakeLinuxClient) { f.bssErr = errors.New("denied") }, 0, false}, + {"disconnected", func(f *fakeLinuxClient) { f.bss["wlan0"] = nil }, 0, false}, + {"authenticated only", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusAuthenticated }, 0, false}, + {"cached nearby BSS", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusNotAssociated }, 0, false}, + {"access point", func(f *fakeLinuxClient) { f.interfaces[0].Type = native.InterfaceTypeAP }, 0, false}, + {"ad hoc", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusIBSSJoined }, 0, false}, + {"station permission denied", func(f *fakeLinuxClient) { + f.stationErr = errors.New("permission denied") + f.bss["wlan0"].Signal = -4200 + }, 1, false}, + {"no station data", func(f *fakeLinuxClient) { f.stations = nil }, 1, false}, + {"different AP", func(f *fakeLinuxClient) { f.stations["wlan0"][0].HardwareAddr = net.HardwareAddr{9, 8, 7, 6, 5, 4} }, 1, false}, + {"missing signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = 0 }, 1, false}, + {"invalid signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = -151 }, 1, false}, + {"deadline failure", func(f *fakeLinuxClient) { f.deadlineErr = errors.New("deadline") }, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + f := connectedClient() + tc.modify(f) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + got := collectLinux(ctx, f) + if len(got) != tc.want { + t.Fatalf("got %#v", got) + } + if tc.want > 0 && (got["wlan0"].Signal != nil) != tc.wantSignal { + t.Fatalf("signal: %#v", got["wlan0"]) + } + if tc.wantSignal && *got["wlan0"].Signal != -52 { + t.Fatal(got) + } + if tc.want == 0 && f.stationCalls != 0 { + t.Fatal("queried station without association") + } + deadline, _ := ctx.Deadline() + if f.deadline != deadline { + t.Fatal("deadline not shared") + } + }) + } +} + +func TestReconnect(t *testing.T) { + f := connectedClient() + if len(collectLinux(context.Background(), f)) != 1 { + t.Fatal("initial") + } + f.bss["wlan0"].Status = native.BSSStatusNotAssociated + if len(collectLinux(context.Background(), f)) != 0 { + t.Fatal("stale association") + } + f.bss["wlan0"].Status = native.BSSStatusAssociated + f.bss["wlan0"].SSID = "new" + if collectLinux(context.Background(), f)["wlan0"].SSID != "new" { + t.Fatal("stale SSID") + } +} + +func TestLinuxInvalidSSID(t *testing.T) { + f := connectedClient() + f.bss["wlan0"].SSID = "raw\xff" + got := collectLinux(context.Background(), f) + if len(got) != 1 || got["wlan0"].SSID != "" || got["wlan0"].Signal == nil { + t.Fatal(got) + } +} + +func TestLinuxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + f := connectedClient() + if len(collectLinux(ctx, f)) != 0 || f.stationCalls != 0 { + t.Fatal("ignored cancellation") + } +} diff --git a/agent/wifi/wifi_test.go b/agent/wifi/wifi_test.go new file mode 100644 index 000000000..dca64eaa0 --- /dev/null +++ b/agent/wifi/wifi_test.go @@ -0,0 +1,35 @@ +package wifi + +import ( + "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/henrygd/beszel/internal/entities/system" +) + +func TestSSIDWireSafety(t *testing.T) { + for _, tc := range []struct{ input, want string }{ + {"home", "home"}, {"网络 café", "网络 café"}, {"", ""}, + {"raw\xffssid", ""}, {"truncated\xe2\x82", ""}, + } { + t.Run(tc.input, func(t *testing.T) { + ssid := validSSID(tc.input) + if ssid != tc.want { + t.Fatalf("got %q, want %q", ssid, tc.want) + } + signal := -50.0 + payload := map[string]system.WiFi{"wlan0": {SSID: ssid, Signal: &signal}} + wire, err := cbor.Marshal(payload) + if err != nil { + t.Fatal(err) + } + var decoded map[string]system.WiFi + if err := cbor.Unmarshal(wire, &decoded); err != nil { + t.Fatal(err) + } + if decoded["wlan0"].Signal == nil || *decoded["wlan0"].Signal != signal || decoded["wlan0"].SSID != tc.want { + t.Fatal(decoded) + } + }) + } +} diff --git a/agent/wifi/wifi_unsupported.go b/agent/wifi/wifi_unsupported.go new file mode 100644 index 000000000..2eb13632a --- /dev/null +++ b/agent/wifi/wifi_unsupported.go @@ -0,0 +1,11 @@ +//go:build !linux && !windows && !darwin + +package wifi + +import ( + "context" + + "github.com/henrygd/beszel/internal/entities/system" +) + +func collect(context.Context) map[string]system.WiFi { return nil } diff --git a/agent/wifi/wifi_windows.go b/agent/wifi/wifi_windows.go new file mode 100644 index 000000000..803621ab7 --- /dev/null +++ b/agent/wifi/wifi_windows.go @@ -0,0 +1,99 @@ +//go:build windows + +package wifi + +import ( + "context" + "unsafe" + + "github.com/henrygd/beszel/internal/entities/system" + "golang.org/x/sys/windows" +) + +var wlan = windows.NewLazySystemDLL("wlanapi.dll") +var wlanOpen = wlan.NewProc("WlanOpenHandle") +var wlanClose = wlan.NewProc("WlanCloseHandle") +var wlanEnum = wlan.NewProc("WlanEnumInterfaces") +var wlanQuery = wlan.NewProc("WlanQueryInterface") +var wlanFree = wlan.NewProc("WlanFreeMemory") + +type wlanInterface struct { + GUID windows.GUID + Description [256]uint16 + State uint32 +} + +type wlanConnection struct { + State uint32 + Mode uint32 + Profile [256]uint16 + SSIDLength uint32 + SSID [32]byte + // Only the prefix through DOT11_SSID is read. +} + +func collect(ctx context.Context) map[string]system.WiFi { + result := make(map[string]system.WiFi) + for _, proc := range []*windows.LazyProc{wlanOpen, wlanClose, wlanEnum, wlanQuery, wlanFree} { + if proc.Find() != nil { + return result + } + } + var handle windows.Handle + var version uint32 + if rc, _, _ := wlanOpen.Call(2, 0, uintptr(unsafe.Pointer(&version)), uintptr(unsafe.Pointer(&handle))); rc != 0 { + return result + } + defer wlanClose.Call(uintptr(handle), 0) + var list unsafe.Pointer + if rc, _, _ := wlanEnum.Call(uintptr(handle), 0, uintptr(unsafe.Pointer(&list))); rc != 0 || list == nil { + return result + } + defer wlanFree.Call(uintptr(list)) + count := *(*uint32)(list) + if count > 1024 { + return result + } + interfaces := unsafe.Slice((*wlanInterface)(unsafe.Add(list, 8)), int(count)) + for _, iface := range interfaces { + if ctx.Err() != nil { + break + } + if iface.State != 1 { + continue + } // wlan_interface_state_connected + reading := system.WiFi{} + // SSID access may be denied by location privacy policy. Association comes + // from the interface state, so missing SSID does not suppress valid RSSI. + if data, size := queryWLAN(handle, &iface.GUID, 7); data != nil { + if size >= uint32(unsafe.Sizeof(wlanConnection{})) { + connection := (*wlanConnection)(data) + if connection.State == 1 && connection.SSIDLength <= 32 { + reading.SSID = validSSID(string(connection.SSID[:connection.SSIDLength])) + } + } + wlanFree.Call(uintptr(data)) + } + // Native RSSI LONG, not the quality percentage in association attributes. + if data, size := queryWLAN(handle, &iface.GUID, 0x10000102); data != nil { + if size >= 4 { + signal := float64(*(*int32)(data)) + if signal >= -150 && signal < 0 { + reading.Signal = &signal + } + } + wlanFree.Call(uintptr(data)) + } + result[iface.GUID.String()] = reading + } + return result +} + +func queryWLAN(handle windows.Handle, guid *windows.GUID, opcode uintptr) (unsafe.Pointer, uint32) { + var data unsafe.Pointer + var size uint32 + if rc, _, _ := wlanQuery.Call(uintptr(handle), uintptr(unsafe.Pointer(guid)), opcode, 0, uintptr(unsafe.Pointer(&size)), uintptr(unsafe.Pointer(&data)), 0); rc != 0 { + return nil, 0 + } + return data, size +} diff --git a/go.mod b/go.mod index de5ba1a7d..acbc8c4df 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gliderlabs/ssh v0.3.8 github.com/lxzan/gws v1.10.2 + github.com/mdlayher/wifi v0.8.0 github.com/nicholas-fedor/shoutrrr v0.21.0 github.com/opencontainers/go-digest v1.0.0 github.com/pocketbase/dbx v1.12.0 @@ -43,6 +44,7 @@ require ( github.com/go-sql-driver/mysql v1.9.1 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -50,6 +52,9 @@ require ( github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mdlayher/genetlink v1.4.0 // indirect + github.com/mdlayher/netlink v1.11.2 // indirect + github.com/mdlayher/socket v0.6.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect diff --git a/go.sum b/go.sum index ff698edce..affc467d1 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,14 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o= +github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= +github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= +github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= +github.com/mdlayher/wifi v0.8.0 h1:qi73hVANXCYJEsT6t147dMILsx9V6UBNipZw0mPYdu0= +github.com/mdlayher/wifi v0.8.0/go.mod h1:QHQ211ZKtZKSKssCznixGUOqBcoyBQAuQWSAOnanY4A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc= diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 7ef366165..e58428123 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -11,7 +11,16 @@ import ( "github.com/henrygd/beszel/internal/entities/systemd" ) +// WiFi describes a currently connected station interface. Keys in WiFi maps are +// OS interface identities, not SSIDs. Signal is native dBm only; nil means the +// OS confirmed association but could not supply RSSI (never convert quality %). +type WiFi struct { + SSID string `json:"s,omitempty" cbor:"0,keyasint,omitempty"` + Signal *float64 `json:"r,omitempty" cbor:"1,keyasint,omitempty"` +} + type Stats struct { + WiFi map[string]WiFi `json:"wifi,omitempty" cbor:"40,keyasint,omitempty"` Cpu float64 `json:"cpu" cbor:"0,keyasint"` MaxCpu float64 `json:"cpum,omitempty" cbor:"-"` Mem float64 `json:"m" cbor:"2,keyasint"` @@ -156,6 +165,7 @@ const ( // Core system data that is needed in All Systems table type Info struct { + // Always serialize the current snapshot, including null on unsupported agents. 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 @@ -184,6 +194,7 @@ type Info struct { Battery Battery `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state] RootDiskName string `json:"rdn,omitempty" cbor:"24,keyasint,omitempty"` // custom name for root disk (set via FILESYSTEM=device__name) PackageUpdates []uint16 `json:"pu,omitempty" cbor:"25,keyasint,omitempty"` // [totalUpdates, securityUpdates] (security omitted if unknown) + WiFi map[string]WiFi `json:"wifi" cbor:"26,keyasint"` } // Data that does not change during process lifetime and is not needed in All Systems table diff --git a/internal/entities/system/wifi_test.go b/internal/entities/system/wifi_test.go new file mode 100644 index 000000000..00bcd937f --- /dev/null +++ b/internal/entities/system/wifi_test.go @@ -0,0 +1,37 @@ +package system + +import ( + "encoding/json" + "testing" + + "github.com/fxamacker/cbor/v2" +) + +func TestWiFiWireSnapshot(t *testing.T) { + signal := -55.0 + for _, wifi := range []map[string]WiFi{nil, {}, {"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {}}} { + original := CombinedData{Info: Info{WiFi: wifi}, Stats: Stats{WiFi: wifi}} + encoded, err := cbor.Marshal(original) + if err != nil { + t.Fatal(err) + } + var decoded CombinedData + if err = cbor.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded.Info.WiFi) != len(wifi) || len(decoded.Stats.WiFi) != len(wifi) { + t.Fatal(decoded) + } + encoded, err = json.Marshal(decoded.Info) + if err != nil { + t.Fatal(err) + } + var info map[string]any + if err = json.Unmarshal(encoded, &info); err != nil { + t.Fatal(err) + } + if _, ok := info["wifi"]; !ok { + t.Fatal("current absence must be explicit") + } + } +} diff --git a/internal/hub/systems/system_wifi_test.go b/internal/hub/systems/system_wifi_test.go new file mode 100644 index 000000000..77b4f5715 --- /dev/null +++ b/internal/hub/systems/system_wifi_test.go @@ -0,0 +1,28 @@ +//go:build testing + +package systems + +import ( + "testing" + + "github.com/henrygd/beszel/internal/entities/system" + "github.com/stretchr/testify/require" +) + +func TestCreateRecordsWiFiDisconnectReconnect(t *testing.T) { + sys, app := newTestSystemWithHub(t) + signal := -50.0 + for _, snapshot := range []map[string]system.WiFi{ + {"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {SSID: "other"}}, + {}, nil, + {"wlan0": {SSID: "new", Signal: &signal}}, + } { + _, err := sys.createRecords(&system.CombinedData{Info: system.Info{WiFi: snapshot}, Stats: system.Stats{WiFi: snapshot}}) + require.NoError(t, err) + record, err := app.FindRecordById("systems", sys.Id) + require.NoError(t, err) + var info system.Info + require.NoError(t, record.UnmarshalJSONField("info", &info)) + require.Len(t, info.WiFi, len(snapshot), "current info must replace previous connection state") + } +} diff --git a/internal/hub/transport/transport.go b/internal/hub/transport/transport.go index 8f99940f6..1a956b49f 100644 --- a/internal/hub/transport/transport.go +++ b/internal/hub/transport/transport.go @@ -35,6 +35,14 @@ func UnmarshalResponse(resp common.AgentResponse, action common.WebSocketAction, } // Try generic Data field first (0.19+) if len(resp.Data) > 0 { + // Wi-Fi maps are complete snapshots. CBOR otherwise merges entries into + // reused destinations, retaining disconnected interfaces and old RSSI. + if action == common.GetData { + if data, ok := dest.(*system.CombinedData); ok { + data.Info.WiFi = nil + data.Stats.WiFi = nil + } + } if err := cbor.Unmarshal(resp.Data, dest); err != nil { return fmt.Errorf("failed to unmarshal generic response data: %w", err) } diff --git a/internal/hub/transport/wifi_test.go b/internal/hub/transport/wifi_test.go new file mode 100644 index 000000000..8682a65a0 --- /dev/null +++ b/internal/hub/transport/wifi_test.go @@ -0,0 +1,35 @@ +package transport + +import ( + "github.com/fxamacker/cbor/v2" + "github.com/henrygd/beszel/internal/common" + "github.com/henrygd/beszel/internal/entities/system" + "github.com/stretchr/testify/require" + "testing" +) + +func TestWiFiSequentialResponseSnapshots(t *testing.T) { + signal := -50.0 + var decoded system.CombinedData + for _, snapshot := range []map[string]system.WiFi{ + {"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {Signal: &signal}}, + {"wlan0": {SSID: "home"}}, {}, nil, + {"wlan1": {SSID: "new", Signal: &signal}}, + } { + payload, err := cbor.Marshal(system.CombinedData{Info: system.Info{WiFi: snapshot}, Stats: system.Stats{WiFi: snapshot}}) + require.NoError(t, err) + require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded)) + require.Len(t, decoded.Info.WiFi, len(snapshot)) + require.Len(t, decoded.Stats.WiFi, len(snapshot)) + for id, want := range snapshot { + require.Equal(t, want, decoded.Info.WiFi[id]) + require.Equal(t, want, decoded.Stats.WiFi[id]) + } + } + // An older generic-response agent may omit both fields entirely. + payload, err := cbor.Marshal(map[int]any{0: map[int]any{}, 1: map[int]any{}}) + require.NoError(t, err) + require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded)) + require.Empty(t, decoded.Info.WiFi) + require.Empty(t, decoded.Stats.WiFi) +} diff --git a/internal/records/records.go b/internal/records/records.go index 7c4bf281c..1539107f2 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -267,6 +267,9 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { return sum } + // RSSI averages exclude absent and unavailable samples. + wifiSums := make(map[string]float64) + wifiCounts := make(map[string]int) // necessary because uint8 is not big enough for the sum batterySum := 0 batteryCount := 0 @@ -285,6 +288,16 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { // Accumulate totals for i := range records { stats := &records[i] + for id, reading := range stats.WiFi { + if sum.WiFi == nil { + sum.WiFi = make(map[string]system.WiFi) + } + sum.WiFi[id] = system.WiFi{SSID: reading.SSID} + if reading.Signal != nil { + wifiSums[id] += *reading.Signal + wifiCounts[id]++ + } + } sum.Cpu += stats.Cpu // accumulate cpu time breakdowns if present @@ -614,6 +627,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats { sum.CpuBreakdown = avg } + for id, reading := range sum.WiFi { + if wifiCounts[id] > 0 { + average := wifiSums[id] / float64(wifiCounts[id]) + reading.Signal = &average + sum.WiFi[id] = reading + } + } + return sum } diff --git a/internal/records/records_wifi_test.go b/internal/records/records_wifi_test.go new file mode 100644 index 000000000..90711dab4 --- /dev/null +++ b/internal/records/records_wifi_test.go @@ -0,0 +1,26 @@ +package records + +import ( + "testing" + + "github.com/henrygd/beszel/internal/entities/system" +) + +func TestWiFiAverageAvailableSamples(t *testing.T) { + a, b, c := -40.0, -60.0, -80.0 + input := []system.Stats{ + {WiFi: map[string]system.WiFi{"wlan0": {SSID: "old", Signal: &a}}}, + {}, + {WiFi: map[string]system.WiFi{"wlan0": {SSID: "new", Signal: &b}, "wlan1": {Signal: &c}, "unknown": {}}}, + } + result := AverageSystemStatsSlice(input) + if len(result.WiFi) != 3 || *result.WiFi["wlan0"].Signal != -50 || *result.WiFi["wlan1"].Signal != -80 || result.WiFi["unknown"].Signal != nil || result.WiFi["wlan0"].SSID != "new" { + t.Fatalf("%#v", result.WiFi) + } + if *input[0].WiFi["wlan0"].Signal != -40 { + t.Fatal("mutated input") + } + if len(AverageSystemStatsSlice([]system.Stats{{}, {}}).WiFi) != 0 { + t.Fatal("invented wifi") + } +} diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index d1e2ec755..e3cdff0c7 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -11,6 +11,7 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts" import { ZfsCharts } from "./system/charts/storage-pool-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts" +import { WiFiChart } from "./system/charts/wifi-chart" import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts" import { LazyContainersTable, @@ -135,6 +136,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { + {hasGpuPowerData && } @@ -211,6 +213,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { + {pageBottomExtraMargin > 0 &&
} diff --git a/internal/site/src/components/routes/system/charts/wifi-chart.tsx b/internal/site/src/components/routes/system/charts/wifi-chart.tsx new file mode 100644 index 000000000..1e97f41f2 --- /dev/null +++ b/internal/site/src/components/routes/system/charts/wifi-chart.tsx @@ -0,0 +1,47 @@ +import { t } from "@lingui/core/macro" +import LineChartDefault from "@/components/charts/line-chart" +import { connectedWiFi, wifiColor } from "@/lib/wifi" +import type { ChartData, SystemRecord, SystemStatsRecord } from "@/types" +import { ChartCard } from "../chart-card" + +export function WiFiChart({ + system, + chartData, + grid, + dataEmpty, +}: { + system: SystemRecord + chartData: ChartData + grid: boolean + dataEmpty: boolean +}) { + const interfaces = connectedWiFi(system) + if (!interfaces.length) return null + const dataPoints = interfaces.map(([id, current]) => ({ + label: current.s ? `${id} (${current.s})` : id, + color: wifiColor(id), + dataKey: ({ stats }: SystemStatsRecord) => stats?.wifi?.[id]?.r, + })) + return ( + + `${id}${value.s ? ` (${value.s})` : ""}: ${value.r == null ? t`Unavailable` : `${value.r} dBm`}`, + ) + .join(" · ")} + > + `${value} dBm`} + contentFormatter={({ value }) => `${value} dBm`} + /> + + ) +} diff --git a/internal/site/src/components/systems-table/systems-table-columns.tsx b/internal/site/src/components/systems-table/systems-table-columns.tsx index 6f60f97c5..8205baa15 100644 --- a/internal/site/src/components/systems-table/systems-table-columns.tsx +++ b/internal/site/src/components/systems-table/systems-table-columns.tsx @@ -38,6 +38,7 @@ import { secondsToUptimeString, } from "@/lib/utils" import { batteryStateTranslations } from "@/lib/i18n" +import { connectedWiFi, strongestWiFiSignal } from "@/lib/wifi" import type { SystemRecord } from "@/types" import { SystemDialog } from "../add-system" import AlertButton from "../alerts/alert-button" @@ -346,6 +347,46 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef t`Wi-Fi`, + size: 80, + Icon: WifiIcon, + header: sortableHeader, + hideSort: true, + sortUndefined: "last", + cell(info) { + const connections = connectedWiFi(info.row.original) + if (!connections.length) { + return null + } + const strongest = connections.reduce((best, current) => + (current[1].r ?? Number.NEGATIVE_INFINITY) > (best[1].r ?? Number.NEGATIVE_INFINITY) ? current : best + ) + const displayedConnections = viewMode === "table" ? [strongest] : connections + const title = connections + .map(([id, wifi]) => `${id}${wifi.s ? ` (${wifi.s})` : ""}: ${wifi.r === undefined ? "—" : `${wifi.r} dBm`}`) + .join("\n") + return ( + + {displayedConnections.map(([id, wifi]) => ( + + {wifi.r === undefined ? "—" : `${wifi.r} dBm`} + + ))} + {viewMode === "table" && connections.length > 1 && ( + +{connections.length - 1} + )} + + ) + }, + }, { accessorFn: ({ info }) => info.sv?.[0], id: "services", diff --git a/internal/site/src/lib/wifi.test.ts b/internal/site/src/lib/wifi.test.ts new file mode 100644 index 000000000..3d3660a99 --- /dev/null +++ b/internal/site/src/lib/wifi.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { connectedWiFi, strongestWiFiSignal, wifiColor } from "./wifi" +import type { SystemInfo } from "@/types" + +const system = (wifi?: SystemInfo["wifi"], status: "up" | "down" = "up") => ({ status, info: { wifi } as SystemInfo }) + +test("current state gates panel, not retained history", () => { + expect(connectedWiFi(system())).toEqual([]) + expect(connectedWiFi(system(null))).toEqual([]) + expect(connectedWiFi(system({}))).toEqual([]) + expect(connectedWiFi(system({ wlan0: { r: -50 } }, "down"))).toEqual([]) + expect(connectedWiFi(system({ wlan0: { r: -50 } }))).toHaveLength(1) + expect(connectedWiFi(system({}))).toHaveLength(0) + expect(connectedWiFi(system({ wlan0: { s: "new", r: -60 } }))[0][0]).toBe("wlan0") +}) + +test("multiple interfaces retain independent stable identities and colors", () => { + const connections = connectedWiFi(system({ wlan1: { s: "same" }, wlan0: { s: "same", r: -40 } })) + expect(connections.map(([id]) => id)).toEqual(["wlan0", "wlan1"]) + expect(wifiColor(connections[0][0])).toBe(wifiColor("wlan0")) + expect(wifiColor("wlan0")).not.toBe(wifiColor("wlan1")) +}) + +test("strongestWiFiSignal returns the strongest current native RSSI", () => { + expect(strongestWiFiSignal(system({ wlan0: { r: -63 }, wlan1: { r: -48 }, wlan2: {} }))).toBe(-48) + expect(strongestWiFiSignal(system({ wlan0: {} }))).toBeUndefined() + expect(strongestWiFiSignal(system({ wlan0: { r: -48 } }, "down"))).toBeUndefined() +}) diff --git a/internal/site/src/lib/wifi.ts b/internal/site/src/lib/wifi.ts new file mode 100644 index 000000000..b1e97669e --- /dev/null +++ b/internal/site/src/lib/wifi.ts @@ -0,0 +1,20 @@ +import type { SystemRecord, WiFi } from "@/types" + +// Current system info is independent of the selected historical chart window. +// No fallback to history: missing data, disconnect and offline all hide the panel. +export function connectedWiFi(system: Pick): [string, WiFi][] { + return system.status === "up" ? Object.entries(system.info?.wifi ?? {}).sort(([a], [b]) => a.localeCompare(b)) : [] +} + +export function strongestWiFiSignal(system: Pick): number | undefined { + const signals = connectedWiFi(system) + .map(([, wifi]) => wifi.r) + .filter((signal): signal is number => signal !== undefined && Number.isFinite(signal)) + return signals.length ? Math.max(...signals) : undefined +} + +export function wifiColor(id: string): string { + let hash = 0 + for (const char of id) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) | 0 + return `hsl(${(hash >>> 0) % 360}, 65%, 52%)` +} diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 6efbc1a61..9fc295b66 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -33,7 +33,13 @@ export interface SystemRecord extends RecordModel { updated: string } +export interface WiFi { + s?: string + r?: number +} + export interface SystemInfo { + wifi?: Record | null /** hostname */ h: string /** kernel **/ @@ -85,6 +91,7 @@ export interface SystemInfo { } export interface SystemStats { + wifi?: Record /** cpu percent */ cpu: number /** peak cpu */