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:
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 |
@@ -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 />
|
||||
@@ -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 />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"@static-data/*": ["../../static-data/*"],
|
||||
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
Reference in new issue
Block a user