diff --git a/apps/docs/docs/integrations/unifi-controller/index.mdx b/apps/docs/docs/integrations/unifi-controller/index.mdx
index 7d9666cb5..7a1a8869b 100644
--- a/apps/docs/docs/integrations/unifi-controller/index.mdx
+++ b/apps/docs/docs/integrations/unifi-controller/index.mdx
@@ -24,6 +24,12 @@ Therefore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro
items={[{ widget: networkControllerSummaryWidget }, { widget: networkControllerStatusWidget }]}
/>
+VPN users are counted from active remote VPN sessions across all sites. Controllers that return HTTP 404 for
+`stat/remoteuservpn` use the legacy health counter instead; a successful empty session list counts as zero.
+This avoids the stale L2TP health counter on UniFi Network 10.6.106. VPN types absent from that endpoint cannot be
+counted. If a site's session request fails or returns an invalid response, the VPN user total is shown as unknown
+while the other network metrics remain available. VPN status still uses the controller's health subsystem.
+
### Adding the integration
diff --git a/apps/docs/public/api/open-api-schema.json b/apps/docs/public/api/open-api-schema.json
index d6d5ce027..30129299f 100644
--- a/apps/docs/public/api/open-api-schema.json
+++ b/apps/docs/public/api/open-api-schema.json
@@ -5923,6 +5923,134 @@
}
}
}
+ },
+ "/api/integrations/{id}/test-connection": {
+ "get": {
+ "operationId": "integration-testConnection",
+ "summary": "Test a saved integration using stored credentials",
+ "description": "Requires full integration access. Runs the native connection check without changing saved configuration. Upstream authentication may establish a session. Success is a connection check, not widget or service acceptance. Failures expose only a safe category, not upstream errors or credentials.",
+ "tags": [
+ "integrations"
+ ],
+ "security": [
+ {
+ "apikey": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "schema": {
+ "type": "string"
+ },
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "const": true
+ }
+ },
+ "required": [
+ "success"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "const": false
+ },
+ "error": {
+ "type": "string",
+ "enum": [
+ "missingSecret",
+ "authorization",
+ "certificate",
+ "request",
+ "statusCode",
+ "parse",
+ "unknown"
+ ]
+ }
+ },
+ "required": [
+ "success",
+ "error"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid input data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/error.BAD_REQUEST"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authorization not provided",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/error.UNAUTHORIZED"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Insufficient access",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/error.FORBIDDEN"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/error.NOT_FOUND"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"components": {
diff --git a/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts b/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts
index 587b5470d..fb2a30ac9 100644
--- a/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts
+++ b/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts
@@ -22,6 +22,6 @@ export interface NetworkControllerSummary {
vpn: {
status: "enabled" | "disabled";
- users: number;
+ users: number | null;
};
}
diff --git a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts
index 2a5be3b8c..20c23ca0d 100644
--- a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts
+++ b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts
@@ -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 {
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;
}
diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx
index d0e8b3a3d..377508e49 100644
--- a/packages/widgets/src/network-controller/summary/component.tsx
+++ b/packages/widgets/src/network-controller/summary/component.tsx
@@ -66,6 +66,10 @@ export default function NetworkControllerSummaryWidget({
);
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({
/>
}
>
-
+
{t("card.vpn.label")}
{showSecondary && (
- {t("card.vpn.countConnected", { count: summary.vpn.users })}
+ {formatVpnCount(summary.vpn.users)}
)}
@@ -234,7 +234,7 @@ export default function NetworkControllerSummaryWidget({
{getMatrixMetricLabel(metric.key, t)}
- {formatMatrixMetric(metric.key, metric.value)}
+ {formatMatrixMetric(metric.key, metric.value, statusLabels.unknown)}
))}
@@ -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);
diff --git a/packages/widgets/src/network-controller/summary/display.ts b/packages/widgets/src/network-controller/summary/display.ts
index 11cb28c29..035490f50 100644
--- a/packages/widgets/src/network-controller/summary/display.ts
+++ b/packages/widgets/src/network-controller/summary/display.ts
@@ -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[] => [