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

+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>
);
}