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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const RUNNING_STATUS = "running";
|
||||
|
||||
export function getStatusColor(status: string) {
|
||||
return status === RUNNING_STATUS ? "green" : "red";
|
||||
}
|
||||
@@ -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