feat: add VPN status widget (#5812)

Co-authored-by: Lokesh-Kumar-Dudi <lkumardudi@gmail.com>
Co-authored-by: Thomas Camlong <thomas@ajnart.fr>
This commit is contained in:
authored and GitHub committed 2026-06-26 14:29:23 +02:00
1 parent f56a05bd8e
commit a2bb53a8b8
36 files changed
+1332 -224

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ export const widgetRouter = createTRPCRouter({
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
});
+59
View File
@@ -0,0 +1,59 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createVpnIntegrationMiddleware = () =>
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = vpnSummaryHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
}),
subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"vpn"> }>;
summary: VpnSummary | null;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});
+2
View File
@@ -21,6 +21,7 @@ import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
import { vpnJob } from "./jobs/integrations/vpn";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -57,6 +58,7 @@ const getJobGroup = () => {
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
vpn: vpnJob,
});
};
@@ -0,0 +1,14 @@
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(vpnSummaryHandler.handler, {
widgetKinds: ["vpn"],
getInput: {
vpn: () => ({}),
},
}),
);
@@ -337,6 +337,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/navidrome"
| "/docs/integrations/umami"
| "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/widgets/clock"
@@ -380,6 +381,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/audio-stats"
| "/docs/widgets/umami"
| "/docs/widgets/vpn"
| "/docs/widgets/ups"
| "/docs/widgets/beszel-system-table"
| "/docs/widgets/beszel-system-grid"
@@ -55,6 +55,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
@@ -43,6 +43,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
uptimeKuma: "uptime-kuma",
audioStats: "audio-stats",
umami: "umami",
vpn: "vpn",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
+8
View File
@@ -489,6 +489,13 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
defaultPort: 8090,
},
gluetun: {
name: "Gluetun",
secretKinds: [["username", "password"], ["apiKey"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
},
archiveTeamWarrior: {
name: "ArchiveTeam Warrior",
secretKinds: [[], ["username", "password"]],
@@ -604,6 +611,7 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"vpn",
"archiving",
"ups",
"documents",
@@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
vpn: getIntegrationKindsByCategory("vpn"),
anchorNote: ["anchor"],
};
+1
View File
@@ -41,6 +41,7 @@ export const widgetKinds = [
"uptimeKuma",
"audioStats",
"umami",
"vpn",
"archiveTeamWarrior",
"ups",
"beszelSystemTable",
@@ -21,6 +21,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { GlancesIntegration } from "../glances/glances-integration";
import { GluetunIntegration } from "../gluetun/gluetun-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration";
import { ImmichIntegration } from "../immich/immich-integration";
@@ -137,6 +138,7 @@ export const integrationCreators = {
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
@@ -0,0 +1,115 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { VpnSummaryIntegration } from "../interfaces/vpn-summary/vpn-summary-integration";
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
import {
gluetunDnsStatusSchema,
gluetunPublicIpSchema,
gluetunVpnSettingsSchema,
gluetunVpnStatusSchema,
} from "./gluetun-types";
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = this.getAuthHeaders();
const url = this.url("/v1/vpn/status");
const response = await input.fetchAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return { success: true };
}
public async getSummaryAsync(): Promise<VpnSummary> {
const authHeaders = this.getAuthHeaders();
const [vpnStatus, dnsStatus, publicIp, vpnSettings] = await Promise.all([
this.getVpnStatusAsync(authHeaders),
this.getDnsStatusAsync(authHeaders),
this.getPublicIpAsync(authHeaders),
this.getVpnSettingAsync(authHeaders),
]);
return {
vpnStatus: vpnStatus.status,
dnsStatus: dnsStatus.status,
publicIp: publicIp.public_ip,
country: publicIp.country,
city: publicIp.city,
vpnProvider: {
protocol: vpnSettings.type,
provider: vpnSettings.provider.name,
},
};
}
private async getVpnStatusAsync(authHeaders: Record<string, string>): Promise<GluetunVpnStatus> {
const url = this.url("/v1/vpn/status");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) throw new ResponseError(response);
return gluetunVpnStatusSchema.parse(await response.json());
}
private async getDnsStatusAsync(authHeaders: Record<string, string>): Promise<GluetunDnsStatus> {
const url = this.url("/v1/dns/status");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) throw new ResponseError(response);
return gluetunDnsStatusSchema.parse(await response.json());
}
private async getPublicIpAsync(authHeaders: Record<string, string>): Promise<GluetunPublicIp> {
const url = this.url("/v1/publicip/ip");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) throw new ResponseError(response);
return gluetunPublicIpSchema.parse(await response.json());
}
private async getVpnSettingAsync(authHeaders: Record<string, string>): Promise<GluetunVpnSettings> {
const url = this.url("/v1/vpn/settings");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) throw new ResponseError(response);
return gluetunVpnSettingsSchema.parse(await response.json());
}
private getAuthHeaders(): Record<string, string> {
if (this.hasSecretValue("apiKey")) {
return { "X-API-Key": this.getSecretValue("apiKey") };
}
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
const username = this.getSecretValue("username");
const password = this.getSecretValue("password");
const encodedCredentials = btoa(`${username}:${password}`);
return { Authorization: `Basic ${encodedCredentials}` };
}
return {};
}
}
@@ -0,0 +1,116 @@
import { z } from "zod/v4";
export const gluetunVpnStatusSchema = z.object({
status: z.string(),
});
export type GluetunVpnStatus = z.infer<typeof gluetunVpnStatusSchema>;
export const gluetunDnsStatusSchema = gluetunVpnStatusSchema;
export type GluetunDnsStatus = z.infer<typeof gluetunDnsStatusSchema>;
export const gluetunPublicIpSchema = z.object({
public_ip: z.string(),
region: z.string(),
country: z.string(),
city: z.string(),
location: z.string(),
organization: z.string(),
postal_code: z.string(),
timezone: z.string(),
});
export type GluetunPublicIp = z.infer<typeof gluetunPublicIpSchema>;
const gluetunServerSelectionOpenvpnSchema = z.object({
config_file_path: z.string(),
protocol: z.string(),
endpoint_ip: z.string(),
custom_port: z.number(),
pia_encryption_preset: z.string(),
});
const gluetunServerSelectionWireguardSchema = z.object({
endpoint_ip: z.string(),
endpoint_port: z.number(),
public_key: z.string(),
});
const gluetunServerSelectionSchema = z.object({
vpn: z.string(),
countries: z.array(z.string()).nullable(),
categories: z.array(z.string()).nullable(),
regions: z.array(z.string()).nullable(),
cities: z.array(z.string()).nullable(),
isps: z.array(z.string()).nullable(),
names: z.array(z.string()).nullable(),
numbers: z.array(z.number()).nullable(),
hostnames: z.array(z.string()),
owned_only: z.boolean(),
free_only: z.boolean(),
premium_only: z.boolean(),
stream_only: z.boolean(),
multi_hop_only: z.boolean(),
port_forward_only: z.boolean(),
secure_core_only: z.boolean(),
tor_only: z.boolean(),
openvpn: gluetunServerSelectionOpenvpnSchema,
wireguard: gluetunServerSelectionWireguardSchema,
});
const gluetunPortForwardingSchema = z.object({
enabled: z.boolean(),
provider: z.string(),
status_file_path: z.string(),
up_command: z.string(),
down_command: z.string(),
listening_port: z.number(),
username: z.string(),
password: z.string(),
});
const gluetunProviderSchema = z.object({
name: z.string(),
server_selection: gluetunServerSelectionSchema,
port_forwarding: gluetunPortForwardingSchema,
});
const gluetunOpenvpnSettingsSchema = z.object({
version: z.string(),
user: z.string(),
password: z.string(),
config_file_path: z.string(),
ciphers: z.array(z.string()).nullable(),
auth: z.string(),
cert: z.string(),
key: z.string(),
encrypted_key: z.string(),
key_passphrase: z.string(),
pia_encryption_preset: z.string(),
mssfix: z.number(),
interface: z.string(),
process_user: z.string(),
verbosity: z.number(),
flags: z.array(z.string()).nullable(),
});
const gluetunWireguardSettingsSchema = z.object({
private_key: z.string(),
pre_shared_key: z.string(),
addresses: z.array(z.string()),
allowed_ips: z.array(z.string()),
interface: z.string(),
persistent_keep_alive_interval: z.number(),
mtu: z.number(),
implementation: z.string(),
});
export const gluetunVpnSettingsSchema = z.object({
type: z.string(),
provider: gluetunProviderSchema,
openvpn: gluetunOpenvpnSettingsSchema,
wireguard: gluetunWireguardSettingsSchema,
});
export type GluetunVpnSettings = z.infer<typeof gluetunVpnSettingsSchema>;
@@ -0,0 +1,371 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../../base/integration";
import { GluetunIntegration } from "../gluetun-integration";
import {
gluetunDnsStatusSchema,
gluetunPublicIpSchema,
gluetunVpnSettingsSchema,
gluetunVpnStatusSchema,
} from "../gluetun-types";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/db")>();
return {
...actual,
db: createDb(),
};
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://gluetun.example.com";
const TEST_API_KEY = "test-api-key";
const TEST_USERNAME = "user";
const TEST_PASSWORD = "p@ss";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
type FetchResponse = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const VPN_STATUS_PAYLOAD = { status: "running" };
const DNS_STATUS_PAYLOAD = { status: "running" };
const PUBLIC_IP_PAYLOAD = {
public_ip: "203.0.113.42",
region: "Europe",
country: "Germany",
city: "Berlin",
location: "52.5200,13.4050",
organization: "Example Org",
postal_code: "10115",
timezone: "Europe/Berlin",
};
const VPN_SETTINGS_PAYLOAD = {
type: "wireguard",
provider: {
name: "mullvad",
server_selection: {
vpn: "wireguard",
countries: ["Germany"],
categories: null,
regions: null,
cities: ["Berlin"],
isps: null,
names: null,
numbers: null,
hostnames: ["de-ber-wg-001"],
owned_only: false,
free_only: false,
premium_only: false,
stream_only: false,
multi_hop_only: false,
port_forward_only: false,
secure_core_only: false,
tor_only: false,
openvpn: {
config_file_path: "",
protocol: "udp",
endpoint_ip: "",
custom_port: 0,
pia_encryption_preset: "",
},
wireguard: {
endpoint_ip: "203.0.113.1",
endpoint_port: 51820,
public_key: "pubkey",
},
},
port_forwarding: {
enabled: false,
provider: "mullvad",
status_file_path: "",
up_command: "",
down_command: "",
listening_port: 0,
username: "",
password: "",
},
},
openvpn: {
version: "",
user: "",
password: "",
config_file_path: "",
ciphers: null,
auth: "",
cert: "",
key: "",
encrypted_key: "",
key_passphrase: "",
pia_encryption_preset: "",
mssfix: 0,
interface: "tun0",
process_user: "",
verbosity: 0,
flags: null,
},
wireguard: {
private_key: "",
pre_shared_key: "",
addresses: ["10.0.0.2/32"],
allowed_ips: ["0.0.0.0/0"],
interface: "wg0",
persistent_keep_alive_interval: 25,
mtu: 1420,
implementation: "auto",
},
};
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
new Response(JSON.stringify(body), {
...init,
headers: { "content-type": "application/json" },
}) as unknown as FetchResponse;
const setupHappyPath = () => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url.toString();
const { pathname } = new URL(urlString);
switch (pathname) {
case "/v1/vpn/status":
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
case "/v1/dns/status":
return Promise.resolve(jsonResponse(DNS_STATUS_PAYLOAD));
case "/v1/publicip/ip":
return Promise.resolve(jsonResponse(PUBLIC_IP_PAYLOAD));
case "/v1/vpn/settings":
return Promise.resolve(jsonResponse(VPN_SETTINGS_PAYLOAD));
default:
return Promise.resolve(jsonResponse({ error: "not found" }, { status: 404 }));
}
});
};
const createIntegrationWithApiKey = () =>
new GluetunIntegration({
id: "gluetun-1",
name: "Gluetun",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
});
const createIntegrationWithBasicAuth = () =>
new GluetunIntegration({
id: "gluetun-2",
name: "Gluetun",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: TEST_USERNAME },
{ kind: "password", value: TEST_PASSWORD },
],
});
describe("GluetunIntegration.getSummaryAsync", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("maps the four upstream responses into a flat VPN summary", async () => {
setupHappyPath();
const result = await createIntegrationWithApiKey().getSummaryAsync();
expect(result).toEqual({
vpnStatus: "running",
dnsStatus: "running",
publicIp: "203.0.113.42",
country: "Germany",
city: "Berlin",
vpnProvider: {
protocol: "wireguard",
provider: "mullvad",
},
});
});
test("hits each of the four control endpoints exactly once", async () => {
setupHappyPath();
await createIntegrationWithApiKey().getSummaryAsync();
const calledPaths = mockFetch.mock.calls.map(([url]) => {
const urlString = typeof url === "string" ? url : url.toString();
return new URL(urlString).pathname;
});
expect(calledPaths).toHaveLength(4);
expect(new Set(calledPaths)).toEqual(
new Set(["/v1/vpn/status", "/v1/dns/status", "/v1/publicip/ip", "/v1/vpn/settings"]),
);
});
test("sends X-API-Key header when an apiKey secret is configured", async () => {
setupHappyPath();
await createIntegrationWithApiKey().getSummaryAsync();
for (const [, init] of mockFetch.mock.calls) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
expect(headers.Authorization).toBeUndefined();
}
});
test("sends Basic auth header when username/password are configured", async () => {
setupHappyPath();
await createIntegrationWithBasicAuth().getSummaryAsync();
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
for (const [, init] of mockFetch.mock.calls) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe(expected);
expect(headers["X-API-Key"]).toBeUndefined();
}
});
test("throws when any upstream endpoint returns a non-OK status", async () => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url.toString();
const { pathname } = new URL(urlString);
if (pathname === "/v1/publicip/ip") {
return Promise.resolve(jsonResponse({ error: "unauthorized" }, { status: 401 }));
}
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
});
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
});
test("throws when an upstream payload fails schema validation", async () => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url.toString();
const { pathname } = new URL(urlString);
if (pathname === "/v1/publicip/ip") {
return Promise.resolve(jsonResponse({ public_ip: 123 }));
}
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
});
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
});
});
describe("GluetunIntegration testing endpoint", () => {
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
const invokeTesting = (integration: GluetunIntegration, input: IntegrationTestingInput) => {
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
return testing(input);
};
const makeInput = (response: Response): IntegrationTestingInput =>
({
fetchAsync: vi.fn().mockResolvedValue(response),
}) as unknown as IntegrationTestingInput;
test("returns success when /v1/vpn/status responds 200", async () => {
const input = makeInput(new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }));
const result = await invokeTesting(createIntegrationWithApiKey(), input);
expect(result.success).toBe(true);
});
test("returns a status-code failure when /v1/vpn/status responds non-OK", async () => {
const input = makeInput(new Response("unauthorized", { status: 401 }));
const result = await invokeTesting(createIntegrationWithApiKey(), input);
expect(result.success).toBe(false);
});
test("passes the api-key header through to fetchAsync", async () => {
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
const input = makeInput(response);
await invokeTesting(createIntegrationWithApiKey(), input);
const fetchAsyncMock = vi.mocked(input.fetchAsync);
const init = fetchAsyncMock.mock.calls[0]?.[1];
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
});
test("passes the Basic auth header through to fetchAsync when username/password are configured", async () => {
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
const input = makeInput(response);
await invokeTesting(createIntegrationWithBasicAuth(), input);
const fetchAsyncMock = vi.mocked(input.fetchAsync);
const init = fetchAsyncMock.mock.calls[0]?.[1];
const headers = (init?.headers ?? {}) as Record<string, string>;
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
expect(headers.Authorization).toBe(expected);
expect(headers["X-API-Key"]).toBeUndefined();
});
});
describe("gluetun schemas", () => {
test("vpn status schema accepts a string status", () => {
expect(gluetunVpnStatusSchema.parse({ status: "running" })).toEqual({ status: "running" });
});
test("vpn status schema rejects a missing status", () => {
expect(() => gluetunVpnStatusSchema.parse({})).toThrow();
});
test("dns status schema mirrors vpn status schema", () => {
expect(gluetunDnsStatusSchema.parse({ status: "stopped" })).toEqual({ status: "stopped" });
expect(() => gluetunDnsStatusSchema.parse({ status: 42 })).toThrow();
});
test("public ip schema parses the full payload", () => {
expect(gluetunPublicIpSchema.parse(PUBLIC_IP_PAYLOAD)).toEqual(PUBLIC_IP_PAYLOAD);
});
test("public ip schema rejects a missing required field", () => {
const { city: _city, ...partial } = PUBLIC_IP_PAYLOAD;
expect(() => gluetunPublicIpSchema.parse(partial)).toThrow();
});
test("vpn settings schema parses a wireguard payload", () => {
const parsed = gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD);
expect(parsed.type).toBe("wireguard");
expect(parsed.provider.name).toBe("mullvad");
});
test("vpn settings schema accepts nullable server-selection arrays", () => {
const payloadWithNulls = {
...VPN_SETTINGS_PAYLOAD,
provider: {
...VPN_SETTINGS_PAYLOAD.provider,
server_selection: {
...VPN_SETTINGS_PAYLOAD.provider.server_selection,
countries: null,
cities: null,
},
},
};
expect(() => gluetunVpnSettingsSchema.parse(payloadWithNulls)).not.toThrow();
});
test("vpn settings schema rejects a non-string type", () => {
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
});
});
@@ -15,9 +15,9 @@ export class GotifyIntegration extends Integration implements INotificationsInte
}
private getHeaders() {
const credentials = Buffer.from(
`${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
).toString("base64");
const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString(
"base64",
);
return { Authorization: `Basic ${credentials}` };
}
@@ -31,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
const parsed = result.data;
return parsed.messages.map((message): Notification => ({
id: String(message.id),
time: new Date(message.date),
title: message.title,
body: message.message,
}));
return parsed.messages.map(
(message): Notification => ({
id: String(message.id),
time: new Date(message.date),
title: message.title,
body: message.message,
}),
);
}
}
@@ -0,0 +1,5 @@
import type { VpnSummary } from "./vpn-summary-types";
export interface VpnSummaryIntegration {
getSummaryAsync(): Promise<VpnSummary>;
}
@@ -0,0 +1,13 @@
export interface VpnProviderDetails {
protocol: string;
provider: string;
}
export interface VpnSummary {
vpnStatus: string;
dnsStatus: string;
publicIp: string;
country: string;
city: string;
vpnProvider: VpnProviderDetails;
}
+2
View File
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/ups-summary/ups-summary-types";
export * from "./interfaces/vpn-summary/vpn-summary-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
@@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
export * from "./navidrome/navidrome-types";
export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
+158 -211
View File
@@ -2,15 +2,7 @@
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, Wait } from "testcontainers";
import {
beforeAll,
beforeEach,
afterAll,
describe,
expect,
test,
vi,
} from "vitest";
import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual =
await importActual<
typeof import("@homarr/core/infrastructure/certificates")
>();
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi
.fn()
.mockImplementation(() => Promise.resolve([])),
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
};
});
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
const makeInput = (
baseUrl: string,
auth:
| { kind: "credentials"; password?: string }
| { kind: "apiKey"; value: string },
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
): IntegrationInput => ({
id: INTEGRATION_ID,
name: "Technitium DNS",
@@ -109,10 +94,7 @@ const makeInput = (
// Fetch an API token appropriate for the server version.
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
async function fetchApiToken(
baseUrl: string,
apiVersion: TechnitiumVersion,
): Promise<string> {
async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise<string> {
if (apiVersion === "v15") {
// v15 exposes a dedicated endpoint for non-expiring API tokens.
const res = await fetch(
@@ -122,9 +104,7 @@ async function fetchApiToken(
}
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
// token that can be used as a long-lived API key via the ?token= query parameter.
const res = await fetch(
`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
);
const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
return ((await res.json()) as { token: string }).token;
}
@@ -144,16 +124,12 @@ const pool: Record<string, ContainerEntry> = {};
beforeAll(async () => {
await Promise.all(
VERSIONS.map(async ({ tag, apiVersion }) => {
const container = await new GenericContainer(
`technitium/dns-server:${tag}`,
)
const container = await new GenericContainer(`technitium/dns-server:${tag}`)
.withExposedPorts(5380)
// Explicitly set the admin password so all versions use the same credentials.
// Without this, older versions may generate a random password on first run.
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
.withWaitStrategy(
Wait.forLogMessage("Technitium DNS Server was started successfully."),
)
.withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
.start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
const apiToken = await fetchApiToken(baseUrl, apiVersion);
@@ -163,238 +139,209 @@ beforeAll(async () => {
}, 120_000);
afterAll(async () => {
await Promise.all(
Object.values(pool).map(({ container }) => container.stop()),
);
await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
});
// ─── version-parameterised suites ────────────────────────────────────────────
describe.each(VERSIONS)(
"Technitium DNS $tag ($apiVersion)",
({ tag, apiVersion }) => {
let baseUrl: string;
let apiToken: string;
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
let baseUrl: string;
let apiToken: string;
beforeAll(() => {
const entry = pool[tag];
if (!entry)
throw new Error(`Container pool entry for ${tag} was not initialized`);
({ baseUrl, apiToken } = entry);
});
beforeAll(() => {
const entry = pool[tag];
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
({ baseUrl, apiToken } = entry);
});
beforeEach(() => {
sessionState[INTEGRATION_ID] = null;
});
beforeEach(() => {
sessionState[INTEGRATION_ID] = null;
});
// ── factory ────────────────────────────────────────────────────────────────
// ── factory ────────────────────────────────────────────────────────────────
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
test("factory creates a working integration (auto-detects version)", async () => {
const integration = await createTechnitiumDnsIntegrationAsync(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
test("factory creates a working integration (auto-detects version)", async () => {
const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
});
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
});
// ── getSummaryAsync ────────────────────────────────────────────────────────
// ── getSummaryAsync ────────────────────────────────────────────────────────
describe("getSummaryAsync", () => {
test("returns valid summary with credentials", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("getSummaryAsync", () => {
test("returns valid summary with credentials", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
expect(result.status).toBe("enabled");
}, 20_000);
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
expect(result.status).toBe("enabled");
}, 20_000);
test("returns valid summary with API key", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
);
test("returns valid summary with API key", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(typeof result.domainsBeingBlocked).toBe("number");
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
}, 20_000);
});
expect(typeof result.domainsBeingBlocked).toBe("number");
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
}, 20_000);
});
// ── session caching ────────────────────────────────────────────────────────
// ── session caching ────────────────────────────────────────────────────────
describe("session caching", () => {
test("login is performed once and token is cached for subsequent calls", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("session caching", () => {
test("login is performed once and token is cached for subsequent calls", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.getSummaryAsync();
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterFirst).toBeDefined();
await integration.getSummaryAsync();
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterFirst).toBeDefined();
await integration.getSummaryAsync();
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
await integration.getSummaryAsync();
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterSecond).toBe(tokenAfterFirst);
}, 20_000);
expect(tokenAfterSecond).toBe(tokenAfterFirst);
}, 20_000);
test("recovers from an expired/invalid stored token by re-logging in", async () => {
// Plant a bad token with the correct version so it hits the right API path before failing
sessionState[INTEGRATION_ID] = {
token: "deliberately-invalid-token",
version: apiVersion,
};
test("recovers from an expired/invalid stored token by re-logging in", async () => {
// Plant a bad token with the correct version so it hits the right API path before failing
sessionState[INTEGRATION_ID] = {
token: "deliberately-invalid-token",
version: apiVersion,
};
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
apiVersion,
);
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
"deliberately-invalid-token",
);
}, 20_000);
});
expect(result.status).toBe("enabled");
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
}, 20_000);
});
// ── blocking state changes ─────────────────────────────────────────────────
// ── blocking state changes ─────────────────────────────────────────────────
describe("blocking state changes", () => {
test("disableAsync permanently disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("blocking state changes", () => {
test("disableAsync permanently disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("enableAsync re-enables blocking after permanent disable", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("enableAsync re-enables blocking after permanent disable", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync();
await integration.enableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync();
await integration.enableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
}, 20_000);
expect(result.status).toBe("enabled");
}, 20_000);
test("disableAsync with duration temporarily disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("disableAsync with duration temporarily disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(120); // 120s → 2 min
const result = await integration.getSummaryAsync();
await integration.disableAsync(120); // 120s → 2 min
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(1);
const result = await integration.getSummaryAsync();
await integration.disableAsync(1);
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("enableAsync cancels an active timed disable immediately", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("enableAsync cancels an active timed disable immediately", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(600); // 10 minutes
await integration.enableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync(600); // 10 minutes
await integration.enableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
}, 20_000);
});
expect(result.status).toBe("enabled");
}, 20_000);
});
// ── testConnectionAsync ────────────────────────────────────────────────────
// ── testConnectionAsync ────────────────────────────────────────────────────
describe("testConnectionAsync", () => {
test("succeeds with valid credentials", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
describe("testConnectionAsync", () => {
test("succeeds with valid credentials", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("succeeds with valid API key", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("succeeds with valid API key", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("fails with wrong password — returns authorization error", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, {
kind: "credentials",
password: "wrong-password",
}),
);
test("fails with wrong password — returns authorization error", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, {
kind: "credentials",
password: "wrong-password",
}),
);
const result = await integration.testConnectionAsync();
const result = await integration.testConnectionAsync();
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
test("fails with invalid API key — returns authorization error", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
apiVersion,
).testConnectionAsync();
test("fails with invalid API key — returns authorization error", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
});
},
);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
});
});
+35
View File
@@ -0,0 +1,35 @@
import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const logger = createLogger({ module: "vpnRequestHandler" });
export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
VpnSummary | null,
IntegrationKindByCategory<"vpn">,
Record<string, never>
>({
async requestAsync(integration, _input) {
try {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
} catch (error) {
// The VPN control server is unreachable (e.g. the container is down).
// Return null so the widget can render an "unavailable" status instead of
// surfacing a hard error, and so it recovers automatically once it is back up.
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
integrationId: integration.id,
integrationKind: integration.kind,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "vpnSummary",
});
+10
View File
@@ -1021,6 +1021,7 @@
"mediaMonitoring": "Monitoring",
"speedtest": "Speed Test",
"analytics": "Analytics",
"vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring"
},
@@ -2408,6 +2409,12 @@
"title": "Instances"
}
},
"vpn": {
"name": "VPN",
"description": "Overview of your VPN connections",
"serviceUnavailable": "VPN unavailable",
"option": {}
},
"ups": {
"name": "UPS",
"description": "Monitor the status of your UPS devices through a NUT server",
@@ -4588,6 +4595,9 @@
},
"umamiActiveVisitors": {
"label": "Umami Active Visitors"
},
"vpn": {
"label": "VPN Status"
}
},
"interval": {
+2 -2
View File
@@ -207,7 +207,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
acc.memory += safeValue(container.memoryUsage);
return acc;
},
{ cpu: 0, memory: 0 }
{ cpu: 0, memory: 0 },
);
}, [containers]);
@@ -283,7 +283,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
</Text>
+2
View File
@@ -52,6 +52,7 @@ import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as ups from "./ups";
import * as vpn from "./vpn";
import * as beszelSystemTable from "./beszel-system-table";
import * as beszelSystemGrid from "./beszel-system-grid";
import * as beszelAlerts from "./beszel-alerts";
@@ -108,6 +109,7 @@ export const widgetImports = {
uptimeKuma,
audioStats,
umami,
vpn,
ups,
beszelSystemTable,
beszelSystemGrid,
+72
View File
@@ -0,0 +1,72 @@
"use client";
import React from "react";
import { Flex, ScrollArea, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition";
import { updateVpnInfoFromSubscription } from "./helpers";
import { VpnIntegrationCard } from "./vpn-card";
export default function VpnWidget({ options, integrationIds }: WidgetComponentProps<"vpn">) {
const [integrations] = clientApi.widget.vpn.getSummaries.useSuspenseQuery(
{
...options,
integrationIds,
},
{
// Initial load only. Live updates arrive via the subscription below, which is
// fed by the VPN cron job publishing to the integration channel.
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.vpn.subscribeSummaries.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.vpn.getSummaries.setData(
{
...options,
integrationIds,
},
(prevData) => updateVpnInfoFromSubscription(prevData, data),
);
},
},
);
if (integrations.length === 1) {
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const vpn = integrations[0]!;
return (
<Flex align="center" justify="center" w="100%" h="100%" px="xs" py="sm">
<VpnIntegrationCard vpn={vpn.summary} integrationName={vpn.integration.name} />
</Flex>
);
}
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" offsetScrollbars>
<Stack w="100%" gap="sm" py="xs" px="xs">
{integrations.map((vpn) => (
<VpnIntegrationCard
key={vpn.integration.id}
vpn={vpn.summary}
integrationName={vpn.integration.name}
variant="list"
/>
))}
</Stack>
</ScrollArea>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { RouterOutputs } from "@homarr/api";
export const RUNNING_STATUS = "running";
type VpnInfoItem = RouterOutputs["widget"]["vpn"]["getSummaries"][number];
export function getStatusColor(status: string) {
return status === RUNNING_STATUS ? "green" : "red";
}
export function updateVpnInfoFromSubscription(
prevData: VpnInfoItem[] | undefined,
data: { integration: { id: string }; summary: VpnInfoItem["summary"] },
): VpnInfoItem[] | undefined {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
}
+14
View File
@@ -0,0 +1,14 @@
import { IconShieldLock } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("vpn", {
icon: IconShieldLock,
createOptions() {
return optionsBuilder.from(() => ({}));
},
supportedIntegrations: getIntegrationKindsByCategory("vpn"),
}).withDynamicImport(() => import("./component"));
+164
View File
@@ -0,0 +1,164 @@
"use client";
import React from "react";
import { Badge, Box, Card, Flex, Group, Text } from "@mantine/core";
import { IconArrowsExchange, IconMapPin, IconShieldCheck, IconShieldX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useRequiredBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { getStatusColor, RUNNING_STATUS } from "./helpers";
export type VpnInfo = RouterOutputs["widget"]["vpn"]["getSummaries"][number]["summary"];
export function VpnIntegrationCard({
vpn,
integrationName,
variant = "single",
}: {
vpn: VpnInfo;
integrationName?: string;
variant?: "single" | "list";
}) {
const board = useRequiredBoard();
const compact = variant === "list";
const content = vpn ? (
<Flex direction="row" w="100%" align="center" gap="xs">
<VpnStatusColumn vpnStatus={vpn.vpnStatus} dnsStatus={vpn.dnsStatus} compact={compact} />
<VpnInfoColumn
integrationName={compact ? integrationName : undefined}
publicIp={vpn.publicIp}
city={vpn.city}
country={vpn.country}
provider={vpn.vpnProvider.provider}
protocol={vpn.vpnProvider.protocol}
compact={compact}
/>
</Flex>
) : (
<VpnUnavailableContent compact={compact} integrationName={compact ? integrationName : undefined} />
);
if (compact) {
return (
<Card withBorder radius={board.itemRadius} p="xs" w="100%">
{content}
</Card>
);
}
return <Box w="100%">{content}</Box>;
}
function VpnUnavailableContent({ compact, integrationName }: { compact: boolean; integrationName?: string }) {
const t = useScopedI18n("widget.vpn");
return (
<Flex direction="row" w="100%" align="center" gap="xs">
{/* Empty statuses render the shield and DNS badge in red (see getStatusColor). */}
<VpnStatusColumn vpnStatus="" dnsStatus="" compact={compact} />
<Flex gap={2} direction="column" w="100%" align="flex-start" style={{ minWidth: 0 }}>
{integrationName ? (
<Text fw={600} size="xs" lh={1.2} c="dimmed" lineClamp={1}>
{integrationName}
</Text>
) : null}
<Text fw={700} size={compact ? "md" : "lg"} lh={1.2} c="red" lineClamp={2}>
{t("serviceUnavailable")}
</Text>
</Flex>
</Flex>
);
}
function VpnStatusColumn({
vpnStatus,
dnsStatus,
compact,
}: {
vpnStatus: string;
dnsStatus: string;
compact: boolean;
}) {
// Connected shows a shield with a check; anything else (stopped or unavailable) shows a shield with a cross.
const ShieldIcon = vpnStatus === RUNNING_STATUS ? IconShieldCheck : IconShieldX;
return (
<Flex gap={4} direction="column" w={compact ? "28%" : "30%"} miw={compact ? 56 : undefined} align="center">
<ShieldIcon stroke={2} color={getStatusColor(vpnStatus)} size={compact ? 44 : 52} />
<DnsStatusBadge status={dnsStatus} />
</Flex>
);
}
function VpnInfoColumn({
integrationName,
publicIp,
city,
country,
provider,
protocol,
compact,
}: {
integrationName?: string;
publicIp: string;
city: string;
country: string;
provider: string;
protocol: string;
compact: boolean;
}) {
return (
<Flex gap={2} direction="column" w="100%" align="flex-start" style={{ minWidth: 0 }}>
{integrationName ? (
<Text fw={600} size="xs" lh={1.2} c="dimmed" lineClamp={1}>
{integrationName}
</Text>
) : null}
<Text fw={700} size={compact ? "xl" : "2xl"} lh={1.1} lineClamp={1}>
{publicIp}
</Text>
<Group gap={4} justify="flex-start" wrap="nowrap">
<IconMapPin size={10} />
<Text fs="italic" fw={500} size="sm" lh={1.2} lineClamp={1}>
{city}, {country}
</Text>
</Group>
<VpnProviderDetails provider={provider} protocol={protocol} />
</Flex>
);
}
function VpnProviderDetails({ provider, protocol }: { provider: string; protocol: string }) {
return (
<Group gap={6} justify="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
{provider}
</Text>
<IconArrowsExchange stroke={1} size={12} style={{ flexShrink: 0 }} />
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
{protocol}
</Text>
</Group>
);
}
function StatusDot({ status, size = 10 }: { status: string; size?: number }) {
return <Box bg={getStatusColor(status)} h={size} w={size} style={{ borderRadius: 999 }} />;
}
function DnsStatusBadge({ status }: { status: string }) {
return (
<Badge
variant="outline"
color={getStatusColor(status)}
size="xs"
radius="xl"
leftSection={<StatusDot status={status} size={5} />}
>
DNS
</Badge>
);
}