mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-02 22:37:47 +02:00
feat: monitor connected Wi-Fi signal per interface (#2367)
This commit is contained in:
@@ -11,7 +11,16 @@ import (
|
||||
"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 {
|
||||
WiFi map[string]WiFi `json:"wifi,omitempty" cbor:"40,keyasint,omitempty"`
|
||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
|
||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||
@@ -156,6 +165,7 @@ 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
|
||||
@@ -184,6 +194,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"`
|
||||
}
|
||||
|
||||
// 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+)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
batterySum := 0
|
||||
batteryCount := 0
|
||||
@@ -285,6 +288,16 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
// Accumulate totals
|
||||
for i := range records {
|
||||
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
|
||||
// accumulate cpu time breakdowns if present
|
||||
@@ -614,6 +627,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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 { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-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 {
|
||||
LazyContainersTable,
|
||||
@@ -135,6 +136,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<FanChart {...coreProps} />
|
||||
|
||||
<BatteryChart system={system} {...coreProps} />
|
||||
<WiFiChart system={system} {...coreProps} />
|
||||
|
||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||
</div>
|
||||
@@ -211,6 +213,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||
<FanChart {...coreProps} />
|
||||
<BatteryChart system={system} {...coreProps} />
|
||||
<WiFiChart system={system} {...coreProps} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
</div>
|
||||
</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,
|
||||
} from "@/lib/utils"
|
||||
import { batteryStateTranslations } from "@/lib/i18n"
|
||||
import { connectedWiFi, strongestWiFiSignal } from "@/lib/wifi"
|
||||
import type { SystemRecord } from "@/types"
|
||||
import { SystemDialog } from "../add-system"
|
||||
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],
|
||||
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
|
||||
}
|
||||
|
||||
export interface WiFi {
|
||||
s?: string
|
||||
r?: number
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
wifi?: Record<string, WiFi> | null
|
||||
/** hostname */
|
||||
h: string
|
||||
/** kernel **/
|
||||
@@ -85,6 +91,7 @@ export interface SystemInfo {
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
wifi?: Record<string, WiFi>
|
||||
/** cpu percent */
|
||||
cpu: number
|
||||
/** peak cpu */
|
||||
|
||||
Reference in New Issue
Block a user