merge: incorporate dev changes and migrate VPN widget

Resolve merge conflicts favoring our simplified caching architecture:
- cron-jobs/index.ts: keep reduced job set (no integration cron jobs)
- en.json: keep reduced cron job labels
- docker-table.tsx: keep refetch() approach, adopt dev's initialData format

Migrate new VPN widget to polling architecture:
- Remove VPN cron job and subscription-based pattern
- Simplify request handler to createIntegrationRequestHandler
- Simplify tRPC router to use getDataAsync + settleIntegrationQueries
- Convert widget from useSuspenseQuery + subscription to useQuery with polling
This commit is contained in:
Thomas Camlong committed 2026-06-26 20:38:13 +02:00
commit 96fe0d55c7
55 files changed
+1568 -305

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useMemo, useState } from "react";
import type { MantineStyleProp } from "@mantine/core";
@@ -541,7 +542,7 @@ export default function DownloadClientsWidget({
enableColumnOrdering: isEditMode,
enableRowVirtualization: true,
rowVirtualizerOptions: { overscan: 5 },
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined },
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, bg: "transparent" },
mantineTableContainerProps: { style: { height: "100%" } },
mantineTableProps: {
className: "downloads-widget-table",
+12
View File
@@ -0,0 +1,12 @@
/* Make the MantineReactTable transparent so the board card's opacity-controlled
background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */
.downloads-widget-table,
.downloads-widget-table .mantine-Table-table,
.downloads-widget-table .mantine-Table-thead,
.downloads-widget-table .mantine-Table-tbody,
.downloads-widget-table .mantine-Table-tfoot,
.downloads-widget-table .mantine-Table-tr,
.downloads-widget-table th,
.downloads-widget-table td {
background-color: transparent !important;
}
+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,
+41
View File
@@ -0,0 +1,41 @@
"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 { VpnIntegrationCard } from "./vpn-card";
export default function VpnWidget({ options, integrationIds }: WidgetComponentProps<"vpn">) {
const { data: integrations = [] } = clientApi.widget.vpn.getSummaries.useQuery({
...options,
integrationIds,
});
if (integrations.length === 1) {
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>
);
}
+5
View File
@@ -0,0 +1,5 @@
export const RUNNING_STATUS = "running";
export function getStatusColor(status: string) {
return status === RUNNING_STATUS ? "green" : "red";
}
+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>
);
}