"use client"; /* eslint-disable react/no-unstable-nested-components -- TimerPopover uses render props to wire controlled targets. */ /* eslint-disable import/no-unassigned-import -- The shared widget stylesheet is loaded for its side effects. */ import "../../widgets-common.css"; import { useState } from "react"; import { ActionIcon, Badge, Box, Button, Card, Flex, Group, Indicator, ScrollArea, Stack, Text, Tooltip, UnstyledButton, } from "@mantine/core"; import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react"; import combineClasses from "clsx"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useIntegrationConnected } from "@homarr/common"; import { integrationDefs } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import type { widgetKind } from "."; import type { WidgetComponentProps } from "../../definition"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state"; import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import classes from "./component.module.css"; import TimerPopover from "./timer-popover"; const dnsLightStatus = (enabled: boolean | undefined) => `var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`; type DnsSummaryResult = RouterOutputs["widget"]["dnsHole"]["summary"][number]; type AvailableDnsSummaryResult = DnsSummaryResult & { summary: NonNullable; integration: DnsSummaryResult["integration"] & { updatedAt: Date }; }; const isAvailableDnsSummaryResult = (result: DnsSummaryResult): result is AvailableDnsSummaryResult => result.summary !== null && result.integration.updatedAt !== undefined; export default function DnsHoleControlsWidget({ options, integrationIds, isEditMode, width, displayMode, }: WidgetComponentProps) { const board = useRequiredBoard(); // DnsHole integrations with interaction permissions const integrationsWithInteractions = useIntegrationsWithInteractAccess() .map(({ id }) => id) .filter((id) => integrationIds.includes(id)); const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds }); const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? []; const summaries = summaryResults.filter(isAvailableDnsSummaryResult); const utils = clientApi.useUtils(); const { mutateAsync: enableDns, isPending: isEnabling, error: enableError, } = clientApi.widget.dnsHole.enable.useMutation({ onSettled: () => void utils.widget.dnsHole.summary.invalidate(), }); const { mutateAsync: disableDns, isPending: isDisabling, error: disableError, } = clientApi.widget.dnsHole.disable.useMutation({ onSettled: () => void utils.widget.dnsHole.summary.invalidate(), }); const toggleDns = async (integrationId: string) => { const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId); if (!integrationStatus?.summary.status) return; utils.widget.dnsHole.summary.setData( { integrationIds, }, (prevData) => { if (!prevData) return []; return prevData.map((item) => item.integration.id === integrationId && item.summary ? { ...item, summary: { ...item.summary, status: undefined, }, } : item, ); }, ); if (integrationStatus.summary.status === "enabled") { await disableDns({ integrationId, duration: 0 }); } else { await enableDns({ integrationId }); } }; // make lists of enabled and disabled interactable integrations (with permissions, not disconnected and not processing) const integrationsSummaries = summaries.reduce( (acc, { summary, integration: { id } }) => integrationsWithInteractions.includes(id) && summary.status != null ? (acc[summary.status].push(id), acc) : acc, { enabled: [] as string[], disabled: [] as string[] }, ); const t = useI18n("widget.dnsHoleControls"); const [bulkPending, setBulkPending] = useState(false); const [bulkFailureCount, setBulkFailureCount] = useState(0); const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0; const actionsPending = bulkPending || isEnabling || isDisabling; const actionError = enableError ?? disableError; const runBulkToggle = async (integrationIdsToToggle: string[]) => { setBulkPending(true); setBulkFailureCount(0); const results = await Promise.allSettled(integrationIdsToToggle.map(toggleDns)); setBulkFailureCount(results.filter(({ status }) => status === "rejected").length); setBulkPending(false); }; if (isInitialWidgetQueryPending(summaryQuery)) return ; return ( {controlAllButtonsVisible && ( ( )} selectedIntegrationIds={integrationsSummaries.enabled} disableDns={(input) => void disableDns(input).catch(() => undefined)} /> )} {summaries.map((summary) => ( void disableDns(input).catch(() => undefined)} data={summary} t={t} hasIconColor={board.iconColor !== null} rootWidth={width} isAdvanced={displayMode === "advanced"} actionsPending={actionsPending} /> ))} {bulkFailureCount > 0 && ( {t("error.bulkActionsFailed", { count: bulkFailureCount })} )} {actionError && ( {t("error.internalServerError")} )} ); } interface ControlsCardProps { integrationsWithInteractions: string[]; toggleDns: (integrationId: string) => Promise; disableDns: (data: { duration: number; integrationId: string }) => void; data: AvailableDnsSummaryResult; t: ReturnType>; hasIconColor: boolean; rootWidth: number; isAdvanced: boolean; actionsPending: boolean; } const ControlsCard: React.FC = ({ integrationsWithInteractions, toggleDns, disableDns, data, t, hasIconColor, rootWidth, isAdvanced, actionsPending, }) => { const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 }); const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined; const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id); // Use all factors to infer the state of the action buttons const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected && !actionsPending; const board = useRequiredBoard(); const iconUrl = integrationDefs[data.integration.kind].iconUrl; const layout = !isAdvanced && rootWidth < 256 ? "sm" : "md"; return ( {layout === "md" && ( )} {layout === "sm" && ( )} {data.integration.name} {layout === "sm" && ( {!isEnabled ? ( void toggleDns(data.integration.id).catch(() => undefined)} disabled={!controlEnabled} size="sm" color="green" variant="light" > ) : ( void toggleDns(data.integration.id).catch(() => undefined)} disabled={!controlEnabled} size="sm" color="red" variant="light" > )} ( )} selectedIntegrationIds={[data.integration.id]} disableDns={disableDns} /> )} {layout === "md" && ( void toggleDns(data.integration.id).catch(() => undefined)} > ) } > {t( `controls.${ !isConnected ? "disconnected" : typeof isEnabled === "undefined" ? "processing" : isEnabled ? "enabled" : "disabled" }` as never, )} )} {layout === "md" && ( ( )} selectedIntegrationIds={[data.integration.id]} disableDns={disableDns} /> )} ); };