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

@@ -0,0 +1,52 @@
---
title: 'Gluetun'
description: 'Connect Gluetun to Homarr as a VPN integration.'
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { gluetunIntegration } from '.';
import { vpnWidget } from '@site/docs/widgets/vpn';
<IntegrationHeader
integration={gluetunIntegration}
categories={['VPN', 'Network']}
/>
[Gluetun](https://github.com/qdm12/gluetun) exposes a control server that Homarr can query to report the VPN tunnel
status, DNS status, public IP address, and active provider details in the VPN widget.
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: vpnWidget,
}]}
/>
### Setting up the Gluetun control server
Refer to the [Gluetun control server documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication) to enable and configure authentication.
### Adding the integration
<AddingIntegration />
### Secrets
Gluetun's control server can be left unprotected or secured with either an API key or HTTP Basic authentication.
Choose the option that matches how your Gluetun instance integration is set up.
<IntegrationSecrets secrets={[{
credentials: [],
steps: [],
},{
credentials: ['username', 'password'],
steps: [],
},{
credentials: ['apiKey'],
steps: [],
},,]} />
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const gluetunIntegration: IntegrationDefinition = {
name: "Gluetun",
description: "VPN control server integration for Homarr's VPN widget.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg",
path: "../../integrations/gluetun",
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+40
View File
@@ -0,0 +1,40 @@
---
title: 'VPN'
description: 'Monitor the status of your VPN integrations.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { vpnWidget } from '.';
import { gluetunIntegration } from '@site/docs/integrations/gluetun';
<WidgetHeader
widget={vpnWidget}
categories={['Network', 'Security']}
/>
The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS
services are up, the currently exposed public IP address, and the VPN provider and protocol being used.
When multiple VPN integrations are attached, the widget renders one card per integration so you can compare their
current status at a glance.
### Screenshots
import singleIntegrationImage from './img/single.png';
<div>
<img src={singleIntegrationImage} alt="VPN widget showing a single integration" width={128 * 3} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: gluetunIntegration,
}]} />
### Adding the widget
<AddingWidget />
+9
View File
@@ -0,0 +1,9 @@
import { WidgetDefinition } from "@site/src/types";
import { IconShieldLock } from "@tabler/icons-react";
export const vpnWidget: WidgetDefinition = {
icon: IconShieldLock,
name: "VPN",
description: "Monitor the connection status, public IP, and provider details of your VPN integrations.",
path: "../../widgets/vpn",
};
@@ -1,4 +1,3 @@
import { headers } from "next/headers";
import Image from "next/image";
import {
Accordion,
@@ -41,11 +40,10 @@ import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import type { PackageJsonDependencies } from "~/versions/package-reader";
import { getPackageVersion } from "~/versions/package-reader";
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css";
export async function generateMetadata() {
@@ -56,32 +54,10 @@ export async function generateMetadata() {
};
}
const getHostAsync = async () => {
if (process.env.HOSTNAME) {
return `${process.env.HOSTNAME}:3000`;
}
return (await headers()).get("host");
};
export default async function AboutPage() {
const baseServerUrl = `http://${await getHostAsync()}`;
const t = await getScopedI18n("management.page.about");
const version = getPackageVersion();
const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
res.json(),
)) as PackageJsonDependencies;
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
res.json(),
)) as typeof githubContributorsJson;
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
res.json(),
)) as typeof crowdinContributorsJson;
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
(res) => res.json(),
)) as typeof openCollectiveContributorsJson;
const dependencies = await getDependenciesAsync();
return (
<div>
@@ -185,7 +161,7 @@ export default async function AboutPage() {
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.sort(([key1], [key2]) => key1.localeCompare(key2))
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export default function ManageErrorPage({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
const tError = useScopedI18n("management.error");
const t = useI18n();
useEffect(() => {
console.error(error);
}, [error]);
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={48} stroke={1.5} />
<Title order={3}>{tError("title")}</Title>
<Text size="sm" c="dimmed" maw={480} ta="center">
{tError("text")}
</Text>
{error.digest && (
<Text size="xs" c="dimmed">
{tError("digest", { digest: error.digest })}
</Text>
)}
<Button variant="light" onClick={unstable_retry}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Center>
);
}
@@ -150,14 +150,18 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
},
];
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
interface DockerTableProps {
initialData?: RouterOutputs["docker"]["getContainers"];
}
export function DockerTable({ initialData }: DockerTableProps) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
initialData: { containers, timestamp },
initialData,
refetchOnMount: false,
});
const relativeTime = useTimeAgo(data.timestamp);
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const mutate = useCallback(() => {
void refetch()
.then(() => {
@@ -174,8 +178,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
});
}, [refetch, tDocker]);
const containers = data?.containers ?? [];
const table = useTranslatedMantineReactTable({
data: data.containers,
data: containers,
state: { isLoading: isFetching },
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
@@ -188,7 +195,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
placeholder: tDocker("table.search", { count: String(containers.length) }),
style: { minWidth: 300 },
autoFocus: true,
},
@@ -23,7 +23,7 @@ export default async function DockerPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable containers={containers} timestamp={timestamp} />
<DockerTable initialData={{ containers, timestamp }} />
</Stack>
</>
);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import crowdinContributors from "../../../../../../../../static-data/translators.json";
import crowdinContributors from "@static-data/translators.json";
export const GET = () => {
return NextResponse.json(crowdinContributors);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import githubContributors from "../../../../../../../../static-data/contributors.json";
import githubContributors from "@static-data/contributors.json";
export const GET = () => {
return NextResponse.json(githubContributors);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
export const GET = () => {
return NextResponse.json(openCollectiveContributors);
@@ -211,7 +211,17 @@ describe("SQLite backup", () => {
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
// The old key must no longer recover the plaintext. With AES-256-CBC this
// usually fails PKCS7 padding validation (throws), but ~1/256 of the time the
// wrong key yields valid padding and returns garbage instead of throwing, so
// assert on the actual property (plaintext is unrecoverable) rather than throwing.
let decryptedWithOldKey: string | null = null;
try {
decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey);
} catch {
// Expected for most wrong keys: padding validation fails.
}
expect(decryptedWithOldKey).not.toBe(plaintext);
sqlite.close();
});
@@ -142,7 +142,8 @@ export const useBackupAnalysis = () => {
phase: "error",
});
throw new Error(
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
{ cause: migrationErr },
);
}
@@ -41,7 +41,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
return itemA.name.localeCompare(itemB.name);
}),
[t],
);
@@ -57,6 +57,7 @@ export const categoryTranslationKeys: Record<string, string> = {
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
vpn: "integration.category.vpn",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
@@ -0,0 +1,10 @@
"use client";
import { createModal } from "@homarr/modals";
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
defaultTitle: (t) => t("docker.title"),
size: 1400,
});
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
export const UserButton = async () => {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import {
IconBrandDocker,
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps {
children: ReactNode;
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
isDockerEnabled?: boolean;
}
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
await signOut({
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{t("docker")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
+1
View File
@@ -3,6 +3,7 @@
"compilerOptions": {
"paths": {
"~/*": ["./src/*"],
"@static-data/*": ["../../static-data/*"],
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
},
"plugins": [