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
This commit is contained in:
henrygd
2026-09-25 18:42:33 -04:00
parent 16e3fbadce
commit 24792aa24f
9 changed files with 37 additions and 72 deletions

View File

@@ -1,47 +1,11 @@
# Connected Wi-Fi signal # Connected Wi-Fi signal
Each default-interval poll reports a snapshot of connected station interfaces Reports connected station interfaces only (no scans). `info.wf` holds the current
in `info.wifi`. Map keys identify interfaces, not networks. `s` is optional SSID snapshot keyed by interface (`s` SSID, `r` RSSI in dBm when available);
metadata; `r` is nullable native RSSI in dBm. Quality percentages are never `stats.wf` stores available RSSI as integer dBm. Collected on the default
converted to dBm. An associated interface without an accessible RSSI still interval only; real-time requests reuse the last snapshot.
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.
The hub panel gates exclusively on current `systems.info.wifi` and system `up` - Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`.
status, independently of the selected historical period. Empty/null snapshots - macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings.
clear it. Historical averages use only available readings per interface; gaps - Windows: native WLAN API, keyed by interface GUID.
are not zero signal. Interface colors and keys remain stable on reconnect. - Other platforms: unsupported.
## 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.

View File

@@ -5,23 +5,12 @@ package wifi
import ( import (
"context" "context"
"math" "math"
"os"
"os/exec"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/henrygd/beszel/internal/entities/system" "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 // 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. // text strings require UTF-8. Metadata must never invalidate the whole response.
func validSSID(ssid string) string { func validSSID(ssid string) string {

View File

@@ -5,6 +5,9 @@ package wifi
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"os"
"os/exec"
"time"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -31,7 +34,10 @@ if (interfaces) {
JSON.stringify(result);` JSON.stringify(result);`
func collect(ctx context.Context) map[string]system.WiFi { 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 { if err != nil {
return nil return nil
} }

View File

@@ -165,7 +165,6 @@ 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
@@ -194,7 +193,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"` 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 // Data that does not change during process lifetime and is not needed in All Systems table

View File

@@ -33,8 +33,8 @@ func TestWiFiWireSnapshot(t *testing.T) {
if err = json.Unmarshal(encoded, &info); err != nil { if err = json.Unmarshal(encoded, &info); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, ok := info["wifi"]; !ok { if _, ok := info["wf"]; ok != (len(wifi) > 0) {
t.Fatal("current absence must be explicit") t.Fatalf("wf present = %v for snapshot %v", ok, wifi)
} }
} }
} }

View File

@@ -38,7 +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 { connectedWiFi, strongestWiFi, 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"
@@ -358,12 +358,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
sortUndefined: "last", sortUndefined: "last",
cell(info) { cell(info) {
const connections = connectedWiFi(info.row.original) const connections = connectedWiFi(info.row.original)
if (!connections.length) { const strongest = strongestWiFi(connections)
if (!strongest) {
return null 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 displayedConnections = viewMode === "table" ? [strongest] : connections
const title = connections const title = connections
.map(([id, wifi]) => `${id}${wifi.s ? ` (${wifi.s})` : ""}: ${wifi.r === undefined ? "—" : `${wifi.r} dBm`}`) .map(([id, wifi]) => `${id}${wifi.s ? ` (${wifi.s})` : ""}: ${wifi.r === undefined ? "—" : `${wifi.r} dBm`}`)

View File

@@ -2,7 +2,7 @@ import { expect, test } from "bun:test"
import { connectedWiFi, strongestWiFiSignal, wifiColor } from "./wifi" import { connectedWiFi, strongestWiFiSignal, wifiColor } from "./wifi"
import type { SystemInfo } from "@/types" 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", () => { test("current state gates panel, not retained history", () => {
expect(connectedWiFi(system())).toEqual([]) expect(connectedWiFi(system())).toEqual([])

View File

@@ -3,14 +3,22 @@ import type { SystemRecord, WiFi } from "@/types"
// Current system info is independent of the selected historical chart window. // Current system info is independent of the selected historical chart window.
// No fallback to history: missing data, disconnect and offline all hide the panel. // No fallback to history: missing data, disconnect and offline all hide the panel.
export function connectedWiFi(system: Pick<SystemRecord, "status" | "info">): [string, WiFi][] { 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)) : [] 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<SystemRecord, "status" | "info">): number | undefined { export function strongestWiFiSignal(system: Pick<SystemRecord, "status" | "info">): number | undefined {
const signals = connectedWiFi(system) return strongestWiFi(connectedWiFi(system))?.[1].r
.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 { export function wifiColor(id: string): string {

View File

@@ -39,7 +39,8 @@ export interface WiFi {
} }
export interface SystemInfo { export interface SystemInfo {
wifi?: Record<string, WiFi> | null /** connected Wi-Fi interfaces */
wf?: Record<string, WiFi>
/** hostname */ /** hostname */
h: string h: string
/** kernel **/ /** kernel **/