feat: monitor connected Wi-Fi signal per interface (#2367)

This commit is contained in:
Vito Cappello
2026-09-25 17:43:57 -04:00
committed by GitHub
parent badd4c8245
commit 86ab0fae8b
24 changed files with 814 additions and 0 deletions

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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",

View 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()
})

View 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%)`
}

View File

@@ -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 */