mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-30 05:17:49 +02:00
feat: monitor connected Wi-Fi signal per interface (#2367)
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
"github.com/henrygd/beszel/agent/btrfs"
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/agent/wifi"
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"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
|
// update system info
|
||||||
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
@@ -274,6 +277,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Battery = systemStats.Battery
|
a.systemInfo.Battery = systemStats.Battery
|
||||||
|
a.systemInfo.WiFi = systemStats.WiFi
|
||||||
a.systemInfo.Uptime, _ = getUptime()
|
a.systemInfo.Uptime, _ = getUptime()
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
a.systemInfo.Threads = a.systemDetails.Threads
|
a.systemInfo.Threads = a.systemDetails.Threads
|
||||||
|
|||||||
46
agent/wifi/README.md
Normal file
46
agent/wifi/README.md
Normal file
@@ -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.
|
||||||
40
agent/wifi/wifi.go
Normal file
40
agent/wifi/wifi.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
43
agent/wifi/wifi_darwin.go
Normal file
43
agent/wifi/wifi_darwin.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
78
agent/wifi/wifi_linux.go
Normal file
78
agent/wifi/wifi_linux.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
133
agent/wifi/wifi_linux_test.go
Normal file
133
agent/wifi/wifi_linux_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
35
agent/wifi/wifi_test.go
Normal file
35
agent/wifi/wifi_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
11
agent/wifi/wifi_unsupported.go
Normal file
11
agent/wifi/wifi_unsupported.go
Normal file
@@ -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 }
|
||||||
99
agent/wifi/wifi_windows.go
Normal file
99
agent/wifi/wifi_windows.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
@@ -10,6 +10,7 @@ require (
|
|||||||
github.com/fxamacker/cbor/v2 v2.9.4
|
github.com/fxamacker/cbor/v2 v2.9.4
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/lxzan/gws v1.10.2
|
github.com/lxzan/gws v1.10.2
|
||||||
|
github.com/mdlayher/wifi v0.8.0
|
||||||
github.com/nicholas-fedor/shoutrrr v0.21.0
|
github.com/nicholas-fedor/shoutrrr v0.21.0
|
||||||
github.com/opencontainers/go-digest v1.0.0
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/pocketbase/dbx v1.12.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/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // 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/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // 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/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
|
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
|
||||||
|
|||||||
8
go.sum
8
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-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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=
|
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"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 {
|
type Stats struct {
|
||||||
|
WiFi map[string]WiFi `json:"wifi,omitempty" cbor:"40,keyasint,omitempty"`
|
||||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
|
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
|
||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
@@ -156,6 +165,7 @@ const (
|
|||||||
|
|
||||||
// Core system data that is needed in All Systems table
|
// Core system data that is needed in All Systems table
|
||||||
type Info struct {
|
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
|
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
|
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
|
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]
|
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)
|
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)
|
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
|
// Data that does not change during process lifetime and is not needed in All Systems table
|
||||||
|
|||||||
37
internal/entities/system/wifi_test.go
Normal file
37
internal/entities/system/wifi_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/hub/systems/system_wifi_test.go
Normal file
28
internal/hub/systems/system_wifi_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,14 @@ func UnmarshalResponse(resp common.AgentResponse, action common.WebSocketAction,
|
|||||||
}
|
}
|
||||||
// Try generic Data field first (0.19+)
|
// Try generic Data field first (0.19+)
|
||||||
if len(resp.Data) > 0 {
|
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 {
|
if err := cbor.Unmarshal(resp.Data, dest); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal generic response data: %w", err)
|
return fmt.Errorf("failed to unmarshal generic response data: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
35
internal/hub/transport/wifi_test.go
Normal file
35
internal/hub/transport/wifi_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -267,6 +267,9 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
return sum
|
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
|
// necessary because uint8 is not big enough for the sum
|
||||||
batterySum := 0
|
batterySum := 0
|
||||||
batteryCount := 0
|
batteryCount := 0
|
||||||
@@ -285,6 +288,16 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
// Accumulate totals
|
// Accumulate totals
|
||||||
for i := range records {
|
for i := range records {
|
||||||
stats := &records[i]
|
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
|
sum.Cpu += stats.Cpu
|
||||||
// accumulate cpu time breakdowns if present
|
// accumulate cpu time breakdowns if present
|
||||||
@@ -614,6 +627,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
sum.CpuBreakdown = avg
|
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
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
internal/records/records_wifi_test.go
Normal file
26
internal/records/records_wifi_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
|||||||
import { ZfsCharts } from "./system/charts/storage-pool-charts"
|
import { ZfsCharts } from "./system/charts/storage-pool-charts"
|
||||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||||
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-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 { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
|
||||||
import {
|
import {
|
||||||
LazyContainersTable,
|
LazyContainersTable,
|
||||||
@@ -135,6 +136,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<FanChart {...coreProps} />
|
<FanChart {...coreProps} />
|
||||||
|
|
||||||
<BatteryChart system={system} {...coreProps} />
|
<BatteryChart system={system} {...coreProps} />
|
||||||
|
<WiFiChart system={system} {...coreProps} />
|
||||||
|
|
||||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +213,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||||
<FanChart {...coreProps} />
|
<FanChart {...coreProps} />
|
||||||
<BatteryChart system={system} {...coreProps} />
|
<BatteryChart system={system} {...coreProps} />
|
||||||
|
<WiFiChart system={system} {...coreProps} />
|
||||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Wi-Fi signal`}
|
||||||
|
description={interfaces
|
||||||
|
.map(
|
||||||
|
([id, value]) =>
|
||||||
|
`${id}${value.s ? ` (${value.s})` : ""}: ${value.r == null ? t`Unavailable` : `${value.r} dBm`}`,
|
||||||
|
)
|
||||||
|
.join(" · ")}
|
||||||
|
>
|
||||||
|
<LineChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
dataPoints={dataPoints}
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
legend={true}
|
||||||
|
tickFormatter={(value) => `${value} dBm`}
|
||||||
|
contentFormatter={({ value }) => `${value} dBm`}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
secondsToUptimeString,
|
secondsToUptimeString,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import { batteryStateTranslations } from "@/lib/i18n"
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
|
import { connectedWiFi, strongestWiFiSignal } from "@/lib/wifi"
|
||||||
import type { SystemRecord } from "@/types"
|
import type { SystemRecord } from "@/types"
|
||||||
import { SystemDialog } from "../add-system"
|
import { SystemDialog } from "../add-system"
|
||||||
import AlertButton from "../alerts/alert-button"
|
import AlertButton from "../alerts/alert-button"
|
||||||
@@ -346,6 +347,46 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorFn: strongestWiFiSignal,
|
||||||
|
id: "wifi",
|
||||||
|
name: () => 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 (
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "system", { id: info.row.original.id })}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex flex-col gap-0.5 min-w-0 py-1 relative z-10"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{displayedConnections.map(([id, wifi]) => (
|
||||||
|
<span key={id} className="tabular-nums whitespace-nowrap">
|
||||||
|
{wifi.r === undefined ? "—" : `${wifi.r} dBm`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{viewMode === "table" && connections.length > 1 && (
|
||||||
|
<span className="text-xs text-muted-foreground">+{connections.length - 1}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.sv?.[0],
|
accessorFn: ({ info }) => info.sv?.[0],
|
||||||
id: "services",
|
id: "services",
|
||||||
|
|||||||
28
internal/site/src/lib/wifi.test.ts
Normal file
28
internal/site/src/lib/wifi.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
20
internal/site/src/lib/wifi.ts
Normal file
20
internal/site/src/lib/wifi.ts
Normal file
@@ -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<SystemRecord, "status" | "info">): [string, WiFi][] {
|
||||||
|
return system.status === "up" ? Object.entries(system.info?.wifi ?? {}).sort(([a], [b]) => a.localeCompare(b)) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strongestWiFiSignal(system: Pick<SystemRecord, "status" | "info">): 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%)`
|
||||||
|
}
|
||||||
7
internal/site/src/types.d.ts
vendored
7
internal/site/src/types.d.ts
vendored
@@ -33,7 +33,13 @@ export interface SystemRecord extends RecordModel {
|
|||||||
updated: string
|
updated: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WiFi {
|
||||||
|
s?: string
|
||||||
|
r?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
|
wifi?: Record<string, WiFi> | null
|
||||||
/** hostname */
|
/** hostname */
|
||||||
h: string
|
h: string
|
||||||
/** kernel **/
|
/** kernel **/
|
||||||
@@ -85,6 +91,7 @@ export interface SystemInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStats {
|
export interface SystemStats {
|
||||||
|
wifi?: Record<string, WiFi>
|
||||||
/** cpu percent */
|
/** cpu percent */
|
||||||
cpu: number
|
cpu: number
|
||||||
/** peak cpu */
|
/** peak cpu */
|
||||||
|
|||||||
Reference in New Issue
Block a user