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:
36 files changed
+1332
-224
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user