mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-29 04:47:46 +02:00
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:
@@ -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.
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`}`)
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
3
internal/site/src/types.d.ts
vendored
3
internal/site/src/types.d.ts
vendored
@@ -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 **/
|
||||||
|
|||||||
Reference in New Issue
Block a user