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:43:05 -04:00
parent 16e3fbadce
commit 24792aa24f
9 changed files with 37 additions and 72 deletions
+8 -44
View File
@@ -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.
-11
View File
@@ -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 {
+7 -1
View File
@@ -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
}
+1 -2
View File
@@ -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
+2 -2
View File
@@ -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)
}
}
}
@@ -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<Syste
sortUndefined: "last",
cell(info) {
const connections = connectedWiFi(info.row.original)
if (!connections.length) {
const strongest = strongestWiFi(connections)
if (!strongest) {
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`}`)
+1 -1
View File
@@ -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([])
+13 -5
View File
@@ -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<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 {
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 {
+2 -1
View File
@@ -39,7 +39,8 @@ export interface WiFi {
}
export interface SystemInfo {
wifi?: Record<string, WiFi> | null
/** connected Wi-Fi interfaces */
wf?: Record<string, WiFi>
/** hostname */
h: string
/** kernel **/