diff --git a/agent/system.go b/agent/system.go
index f31e13a3..66d7fa74 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 00000000..1cdc0350
--- /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 00000000..f45a7a06
--- /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 00000000..40f07863
--- /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 00000000..fa71e0dc
--- /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 00000000..9a83042c
--- /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 00000000..dca64eaa
--- /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 00000000..2eb13632
--- /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 00000000..803621ab
--- /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 de5ba1a7..acbc8c4d 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 ff698edc..affc467d 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 7ef36616..e5842812 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 00000000..00bcd937
--- /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 00000000..77b4f571
--- /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 8f99940f..1a956b49 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 00000000..8682a65a
--- /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 7c4bf281..1539107f 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 00000000..90711dab
--- /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 d1e2ec75..e3cdff0c 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 }) {