fix(unifi): count active remote VPN sessions (#7013)

This commit is contained in:
Thomas authored and GitHub committed 2026-10-03 23:14:37 +02:00
1 parent 83ae98847a
commit c1dd2b3bca
6 files changed
+190 -12

No files matched your search

@@ -22,6 +22,6 @@ export interface NetworkControllerSummary {
vpn: {
status: "enabled" | "disabled";
users: number;
users: number | null;
};
}
@@ -1,6 +1,7 @@
import type tls from "node:tls";
import axios from "axios";
import axios, { isAxiosError } from "axios";
import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http";
import { z } from "zod/v4";
import {
getAllTrustedCertificatesAsync,
@@ -11,6 +12,7 @@ import {
fetchWithTrustedCertificatesAsync,
getHttpRequestSignal,
} from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { SiteStats } from "@homarr/node-unifi";
import Unifi from "@homarr/node-unifi";
@@ -25,6 +27,10 @@ import type { NetworkControllerSummaryIntegration } from "../interfaces/network-
import type { NetworkControllerSummary } from "../interfaces/network-controller-summary/network-controller-summary-types";
import type { HealthSubsystem } from "./unifi-controller-types";
const logger = createLogger({ module: "unifi-controller-integration" });
const remoteVpnSessionsSchema = z.array(z.object({ up: z.boolean() }));
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration {
private readonly httpSessionStore = createSessionStore<{ cookie: string; csrfToken?: string }>(this.integration);
@@ -45,6 +51,43 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
const client = await this.createControllerClientAsync();
const stats = await client.getSitesStats();
const vpnUserCounts = await Promise.all(
stats.map(async (site) => {
try {
const sessions: unknown = await client.customApiRequest(
`/api/s/${encodeURIComponent(site.name)}/stat/remoteuservpn`,
);
return remoteVpnSessionsSchema.parse(sessions).filter((session) => session.up).length;
} catch (error) {
// Older controllers may not expose remote sessions. An empty successful
// response is authoritative; only an unsupported endpoint uses health.
if (isAxiosError(error) && error.response?.status === 404) {
return this.getNumericValueOverAllSites([site], "vpn", (vpn) => vpn.remote_user_num_active, "sum");
}
let status: number | undefined;
if (isAxiosError(error)) status = error.response?.status;
let reason = "request-failed";
if (error instanceof z.ZodError) reason = "invalid-response";
logger.warn("UniFi remote VPN session count unavailable", {
integrationId: this.integration.id,
site: site.name,
status,
reason,
});
return null;
}
}),
);
// A partial site total cannot describe the controller's connected users.
let vpnUsers: number | null = 0;
for (const count of vpnUserCounts) {
if (count === null) {
vpnUsers = null;
break;
}
vpnUsers += count;
}
return {
wanStatus: this.getStatusValueOverAllSites(stats, "wan", (site) => site.status === "ok"),
@@ -66,7 +109,7 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
},
vpn: {
status: this.getStatusValueOverAllSites(stats, "vpn", (site) => site.status === "ok"),
users: this.getNumericValueOverAllSites(stats, "vpn", (site) => site.remote_user_num_active, "sum"),
users: vpnUsers,
},
} satisfies NetworkControllerSummary;
}
@@ -66,6 +66,10 @@ export default function NetworkControllerSummaryWidget({
</Group>
);
const firstSummary = summaries[0];
const formatVpnCount = (count: number | null) => {
if (count === null) return statusLabels.unknown;
return t("card.vpn.countConnected", { count });
};
let responsiveWidth = width;
let responsiveHeight = height;
@@ -154,15 +158,11 @@ export default function NetworkControllerSummaryWidget({
/>
}
>
<Text
title={t("card.vpn.countConnected", { count: summary.vpn.users })}
truncate="end"
style={{ minWidth: 0 }}
>
<Text title={formatVpnCount(summary.vpn.users)} truncate="end" style={{ minWidth: 0 }}>
{t("card.vpn.label")}
{showSecondary && (
<Text c="dimmed" size={secondaryTextSize} ms="xs" span>
{t("card.vpn.countConnected", { count: summary.vpn.users })}
{formatVpnCount(summary.vpn.users)}
</Text>
)}
</Text>
@@ -234,7 +234,7 @@ export default function NetworkControllerSummaryWidget({
{getMatrixMetricLabel(metric.key, t)}
</Text>
<Text size="xs" fw={500}>
{formatMatrixMetric(metric.key, metric.value)}
{formatMatrixMetric(metric.key, metric.value, statusLabels.unknown)}
</Text>
</Stack>
))}
@@ -257,7 +257,8 @@ const getMatrixMetricLabel = (
t: ScopedTranslationFunction<"widget.networkControllerSummary">,
) => t(`advanced.metric.${key}` as never);
const formatMatrixMetric = (key: NetworkControllerMatrixMetricKey, value: number) => {
const formatMatrixMetric = (key: NetworkControllerMatrixMetricKey, value: number | null, unknown: string) => {
if (value === null) return unknown;
if (key === "latency" || key === "ping") return `${value}ms`;
if (key === "uptime") return dayjs.duration(value, "seconds").humanize();
return String(value);
@@ -6,7 +6,7 @@ export type NetworkControllerMatrixMetricKey = "latency" | "ping" | "uptime" | "
export interface NetworkControllerMatrixSection {
key: NetworkControllerMatrixSectionKey;
status: "enabled" | "disabled";
metrics: { key: NetworkControllerMatrixMetricKey; value: number }[];
metrics: { key: NetworkControllerMatrixMetricKey; value: number | null }[];
}
export const getNetworkControllerMatrix = (summary: NetworkControllerSummary): NetworkControllerMatrixSection[] => [