From 24792aa24fb91dfc0dc93394b7fcd51c520e9650 Mon Sep 17 00:00:00 2001 From: henrygd Date: Fri, 25 Sep 2026 18:42:33 -0400 Subject: [PATCH] wifi: omit empty info snapshot and tidy up - Info.WiFi uses json "wf" with omitempty so systems without Wi-Fi no longer store/broadcast "wifi":null - move osascript exec into wifi_darwin.go; drop unused commandRunner - share strongest-connection logic between table cell and sorting - trim agent/wifi README --- agent/wifi/README.md | 52 +++---------------- agent/wifi/wifi.go | 11 ---- agent/wifi/wifi_darwin.go | 8 ++- internal/entities/system/system.go | 3 +- internal/entities/system/wifi_test.go | 4 +- .../systems-table/systems-table-columns.tsx | 8 ++- internal/site/src/lib/wifi.test.ts | 2 +- internal/site/src/lib/wifi.ts | 18 +++++-- internal/site/src/types.d.ts | 3 +- 9 files changed, 37 insertions(+), 72 deletions(-) diff --git a/agent/wifi/README.md b/agent/wifi/README.md index b4f38f04c..192c02628 100644 --- a/agent/wifi/README.md +++ b/agent/wifi/README.md @@ -1,47 +1,11 @@ # Connected Wi-Fi signal -Each default-interval poll reports a snapshot of connected station interfaces -in `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. -`stats.wf` stores only available RSSI values (integer dBm) keyed by interface. -Real-time requests reuse the last snapshot instead of collecting again. +Reports connected station interfaces only (no scans). `info.wf` holds the current +snapshot keyed by interface (`s` SSID, `r` RSSI in dBm when available); +`stats.wf` stores available RSSI as integer dBm. Collected on the default +interval only; real-time requests reuse the last snapshot. -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. - -## 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 default-interval 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. +- Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`. +- macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings. +- Windows: native WLAN API, keyed by interface GUID. +- Other platforms: unsupported. diff --git a/agent/wifi/wifi.go b/agent/wifi/wifi.go index 2d86deae0..016e9f1a9 100644 --- a/agent/wifi/wifi.go +++ b/agent/wifi/wifi.go @@ -5,23 +5,12 @@ package wifi import ( "context" "math" - "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 { diff --git a/agent/wifi/wifi_darwin.go b/agent/wifi/wifi_darwin.go index 40f07863f..d24911ad3 100644 --- a/agent/wifi/wifi_darwin.go +++ b/agent/wifi/wifi_darwin.go @@ -5,6 +5,9 @@ package wifi import ( "context" "encoding/json" + "os" + "os/exec" + "time" "github.com/henrygd/beszel/internal/entities/system" ) @@ -31,7 +34,10 @@ if (interfaces) { JSON.stringify(result);` func collect(ctx context.Context) map[string]system.WiFi { - output, err := run(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript) + cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript) + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + cmd.WaitDelay = 100 * time.Millisecond + output, err := cmd.Output() if err != nil { return nil } diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index a688475ee..8b9c21d46 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -165,7 +165,6 @@ 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 @@ -194,7 +193,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"` + WiFi map[string]WiFi `json:"wf,omitempty" cbor:"26,keyasint,omitempty"` // connected Wi-Fi interfaces } // 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 index bb7431eaa..48e95449a 100644 --- a/internal/entities/system/wifi_test.go +++ b/internal/entities/system/wifi_test.go @@ -33,8 +33,8 @@ func TestWiFiWireSnapshot(t *testing.T) { if err = json.Unmarshal(encoded, &info); err != nil { t.Fatal(err) } - if _, ok := info["wifi"]; !ok { - t.Fatal("current absence must be explicit") + if _, ok := info["wf"]; ok != (len(wifi) > 0) { + t.Fatalf("wf present = %v for snapshot %v", ok, wifi) } } } 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 8205baa15..d4a6abb53 100644 --- a/internal/site/src/components/systems-table/systems-table-columns.tsx +++ b/internal/site/src/components/systems-table/systems-table-columns.tsx @@ -38,7 +38,7 @@ import { secondsToUptimeString, } from "@/lib/utils" import { batteryStateTranslations } from "@/lib/i18n" -import { connectedWiFi, strongestWiFiSignal } from "@/lib/wifi" +import { connectedWiFi, strongestWiFi, strongestWiFiSignal } from "@/lib/wifi" import type { SystemRecord } from "@/types" import { SystemDialog } from "../add-system" import AlertButton from "../alerts/alert-button" @@ -358,12 +358,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef - (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`}`) diff --git a/internal/site/src/lib/wifi.test.ts b/internal/site/src/lib/wifi.test.ts index 3d3660a99..e3e721f75 100644 --- a/internal/site/src/lib/wifi.test.ts +++ b/internal/site/src/lib/wifi.test.ts @@ -2,7 +2,7 @@ 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 }) +const system = (wf?: SystemInfo["wf"], status: "up" | "down" = "up") => ({ status, info: { wf } as SystemInfo }) test("current state gates panel, not retained history", () => { expect(connectedWiFi(system())).toEqual([]) diff --git a/internal/site/src/lib/wifi.ts b/internal/site/src/lib/wifi.ts index b1e97669e..ca156490f 100644 --- a/internal/site/src/lib/wifi.ts +++ b/internal/site/src/lib/wifi.ts @@ -3,14 +3,22 @@ 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)) : [] + return system.status === "up" ? Object.entries(system.info?.wf ?? {}).sort(([a], [b]) => a.localeCompare(b)) : [] +} + +/** Strongest connection by RSSI, falling back to the first when none report a signal. */ +export function strongestWiFi(connections: [string, WiFi][]): [string, WiFi] | undefined { + let strongest = connections[0] + for (const connection of connections) { + if ((connection[1].r ?? -Infinity) > (strongest[1].r ?? -Infinity)) { + strongest = connection + } + } + return strongest } 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 + return strongestWiFi(connectedWiFi(system))?.[1].r } export function wifiColor(id: string): string { diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index abc0a28d3..a67201f0f 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -39,7 +39,8 @@ export interface WiFi { } export interface SystemInfo { - wifi?: Record | null + /** connected Wi-Fi interfaces */ + wf?: Record /** hostname */ h: string /** kernel **/