From 36915d95fe45964f0291fb8c9cbc46ffecdf2681 Mon Sep 17 00:00:00 2001 From: SeDemal Date: Tue, 24 Sep 2024 23:25:13 +0200 Subject: [PATCH] feat: DnsHole feature parity with oldmarr (#1145) * feat: DnsHole feature parity with oldmarr feat: advanced control management feat: disconnected state fix: summary widget sizing feat: summary text flash on update * feat: dnshole summary integrations disconnected error page * fix: classnaming * refactor: small rename, console to logger and unnecessary as conversion changes --------- Co-authored-by: Meier Lukas --- packages/api/src/router/widgets/dns-hole.ts | 66 ++-- packages/api/src/router/widgets/downloads.ts | 8 +- packages/cron-jobs/src/index.ts | 2 + .../src/jobs/integrations/dns-hole.ts | 28 ++ .../adguard-home/adguard-home-integration.ts | 2 +- .../dns-hole-summary-types.ts | 2 +- packages/integrations/src/types.ts | 1 + packages/translation/src/lang/en.ts | 6 + .../src/dns-hole/controls/component.tsx | 333 +++++++++++++----- .../widgets/src/dns-hole/controls/index.ts | 4 +- .../src/dns-hole/controls/serverData.ts | 4 +- .../src/dns-hole/summary/component.tsx | 96 +++-- .../widgets/src/dns-hole/summary/index.ts | 4 +- .../src/dns-hole/summary/serverData.ts | 4 +- packages/widgets/src/downloads/component.tsx | 13 +- packages/widgets/src/widgets-common.css | 39 ++ 16 files changed, 467 insertions(+), 145 deletions(-) create mode 100644 packages/cron-jobs/src/jobs/integrations/dns-hole.ts diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 41c09c1b2..0257c1d7c 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -1,44 +1,68 @@ -import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; +import type { Modify } from "@homarr/common/types"; +import type { Integration } from "@homarr/db/schema/sqlite"; +import type { IntegrationKindByCategory, WidgetKind } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { integrationCreator } from "@homarr/integrations"; import type { DnsHoleSummary } from "@homarr/integrations/types"; -import { logger } from "@homarr/log"; -import { createCacheChannel } from "@homarr/redis"; +import { controlsInputSchema } from "@homarr/integrations/types"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { z } from "@homarr/validation"; -import { controlsInputSchema } from "../../../../integrations/src/pi-hole/pi-hole-types"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const dnsHoleRouter = createTRPCRouter({ summary: publicProcedure + .input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) })) .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) - .query(async ({ ctx }) => { + .query(async ({ input: { widgetKind }, ctx }) => { const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const cache = createCacheChannel(`dns-hole-summary:${integration.id}`); - const { data } = await cache.consumeAsync(async () => { - const client = integrationCreator(integration); - - return await client.getSummaryAsync().catch((err) => { - logger.error("dns-hole router - ", err); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to fetch DNS Hole summary for ${integration.name} (${integration.id})`, - }); - }); - }); + ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => { + const channel = createItemAndIntegrationChannel(widgetKind, integration.id); + const { data: summary, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) }; return { - integrationId: integration.id, - integrationKind: integration.kind, - summary: data, + integration, + timestamp, + summary, }; }), ); return results; }), + subscribeToSummary: publicProcedure + .input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) })) + .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) + .subscription(({ input: { widgetKind }, ctx }) => { + return observable<{ + integration: Modify }>; + timestamp: Date; + summary: DnsHoleSummary; + }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const channel = createItemAndIntegrationChannel(widgetKind as WidgetKind, integration.id); + const unsubscribe = channel.subscribe((summary) => { + emit.next({ + integration, + timestamp: new Date(), + summary, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), + enable: publicProcedure .unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) .mutation(async ({ ctx: { integration } }) => { diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index 927a0c438..5627a7b4e 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -1,6 +1,8 @@ import { observable } from "@trpc/server/observable"; +import type { Modify } from "@homarr/common/types"; import type { Integration } from "@homarr/db/schema/sqlite"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations"; @@ -33,7 +35,11 @@ export const downloadsRouter = createTRPCRouter({ subscribeToJobsAndStatuses: publicProcedure .unstable_concat(createDownloadClientIntegrationMiddleware("query")) .subscription(({ ctx }) => { - return observable<{ integration: Integration; timestamp: Date; data: DownloadClientJobsAndStatus }>((emit) => { + return observable<{ + integration: Modify }>; + timestamp: Date; + data: DownloadClientJobsAndStatus; + }>((emit) => { const unsubscribes: (() => void)[] = []; for (const integrationWithSecrets of ctx.integrations) { const { decryptedSecrets: _, ...integration } = integrationWithSecrets; diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index f6289859e..d391c3239 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -1,5 +1,6 @@ import { analyticsJob } from "./jobs/analytics"; import { iconsUpdaterJob } from "./jobs/icons-updater"; +import { dnsHoleJob } from "./jobs/integrations/dns-hole"; import { downloadsJob } from "./jobs/integrations/downloads"; import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; import { indexerManagerJob } from "./jobs/integrations/indexer-manager"; @@ -19,6 +20,7 @@ export const jobGroup = createCronJobGroup({ mediaServer: mediaServerJob, mediaOrganizer: mediaOrganizerJob, downloads: downloadsJob, + dnsHole: dnsHoleJob, mediaRequests: mediaRequestsJob, rssFeeds: rssFeedsJob, indexerManager: indexerManagerJob, diff --git a/packages/cron-jobs/src/jobs/integrations/dns-hole.ts b/packages/cron-jobs/src/jobs/integrations/dns-hole.ts new file mode 100644 index 000000000..aaeaf4bee --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/dns-hole.ts @@ -0,0 +1,28 @@ +import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import { integrationCreatorFromSecrets } from "@homarr/integrations"; +import type { DnsHoleSummary } from "@homarr/integrations/types"; +import { logger } from "@homarr/log"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; + +import { createCronJob } from "../../lib"; + +export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(async () => { + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: ["dnsHoleSummary", "dnsHoleControls"], + }); + + for (const itemForIntegration of itemsForIntegration) { + for (const { integration } of itemForIntegration.integrations) { + const integrationInstance = integrationCreatorFromSecrets(integration); + await integrationInstance + .getSummaryAsync() + .then(async (data) => { + const channel = createItemAndIntegrationChannel(itemForIntegration.kind, integration.id); + await channel.publishAndUpdateLastStateAsync(data); + }) + .catch((error) => logger.error(`Could not retrieve data for ${integration.name}: "${error}"`)); + } + } +}); diff --git a/packages/integrations/src/adguard-home/adguard-home-integration.ts b/packages/integrations/src/adguard-home/adguard-home-integration.ts index 2e41bd916..e8b6c5e1a 100644 --- a/packages/integrations/src/adguard-home/adguard-home-integration.ts +++ b/packages/integrations/src/adguard-home/adguard-home-integration.ts @@ -77,7 +77,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar return { status: status.data.protection_enabled ? ("enabled" as const) : ("disabled" as const), adsBlockedToday: blockedQueriesToday, - adsBlockedTodayPercentage: (queriesToday / blockedQueriesToday) * 100, + adsBlockedTodayPercentage: blockedQueriesToday > 0 ? (queriesToday / blockedQueriesToday) * 100 : 0, domainsBeingBlocked: countFilteredDomains, dnsQueriesToday: queriesToday, }; diff --git a/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts index 749360175..a1757ddba 100644 --- a/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts +++ b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts @@ -1,5 +1,5 @@ export interface DnsHoleSummary { - status: "enabled" | "disabled"; + status?: "enabled" | "disabled"; domainsBeingBlocked: number; adsBlockedToday: number; adsBlockedTodayPercentage: number; diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 445894eb4..0af72f1d7 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -2,3 +2,4 @@ export * from "./calendar-types"; export * from "./interfaces/dns-hole-summary/dns-hole-summary-types"; export * from "./interfaces/indexer-manager/indexer"; export * from "./interfaces/media-requests/media-request"; +export * from "./pi-hole/pi-hole-types"; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index f665fa2d7..e1248959b 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -795,6 +795,7 @@ export default { }, error: { internalServerError: "Failed to fetch DNS Hole Summary", + integrationsDisconnected: "No data available, all integrations disconnected", }, data: { adsBlockedToday: "Blocked today", @@ -835,6 +836,8 @@ export default { set: "Set", enabled: "Enabled", disabled: "Disabled", + processing: "Processing", + disconnected: "Disconnected", hours: "Hours", minutes: "Minutes", unlimited: "Leave blank to unlimited", @@ -1839,6 +1842,9 @@ export default { indexerManager: { label: "Indexer Manager", }, + dnsHole: { + label: "DNS Hole Data", + }, }, }, }, diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index dd13969c3..b5f71b46e 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -1,140 +1,228 @@ "use client"; -import { useEffect, useState } from "react"; -import { ActionIcon, Badge, Button, Card, Flex, Image, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; -import { IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react"; +import "../../widgets-common.css"; +import { useState } from "react"; +import { + ActionIcon, + Badge, + Button, + Card, + Flex, + Image, + ScrollArea, + Stack, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react"; +import dayjs from "dayjs"; + +import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { integrationDefs } from "@homarr/definitions"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; +import { widgetKind } from "."; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationSelectedError } from "../../errors"; import TimerModal from "./TimerModal"; -const dnsLightStatus = (enabled: boolean): "green" | "red" => (enabled ? "green" : "red"); +const dnsLightStatus = (enabled: boolean | undefined) => + `var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`; -export default function DnsHoleControlsWidget({ options, integrationIds }: WidgetComponentProps<"dnsHoleControls">) { - if (integrationIds.length === 0) { - throw new NoIntegrationSelectedError(); - } - const t = useI18n(); - const [status, setStatus] = useState<{ integrationId: string; enabled: boolean }[]>( - integrationIds.map((id) => ({ integrationId: id, enabled: false })), - ); - const [selectedIntegrationIds, setSelectedIntegrationIds] = useState([]); - const [opened, { close, open }] = useDisclosure(false); +export default function DnsHoleControlsWidget({ + options, + integrationIds, + isEditMode, + serverData, +}: WidgetComponentProps) { + // DnsHole integrations with interaction permissions + const integrationsWithInteractions = useIntegrationsWithInteractAccess() + .map(({ id }) => id) + .filter((id) => integrationIds.includes(id)); - const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery( + // Initial summaries, null summary means disconnected, undefined status means processing + const [summaries, setSummaries] = useState(serverData?.initialData ?? []); + + // Subscribe to summary updates + clientApi.widget.dnsHole.subscribeToSummary.useSubscription( { + widgetKind, integrationIds, }, { - refetchOnMount: false, - retry: false, + onData: (data) => { + setSummaries((prevSummaries) => + prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)), + ); + }, }, ); - useEffect(() => { - const newStatus = data.map((integrationData) => ({ - integrationId: integrationData.integrationId, - enabled: integrationData.summary.status === "enabled", - })); - setStatus(newStatus); - }, [data]); - + // Mutations for dnsHole state, set to undefined on click, and change again on settle const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({ - onSuccess: (_, variables) => { - setStatus((prevStatus) => - prevStatus.map((item) => (item.integrationId === variables.integrationId ? { ...item, enabled: true } : item)), + onSettled: (_, error, { integrationId }) => { + setSummaries((prevSummaries) => + prevSummaries.map((data) => ({ + ...data, + summary: + data.integration.id === integrationId && data.summary + ? { ...data.summary, status: error ? "disabled" : "enabled" } + : data.summary, + })), ); }, }); const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({ - onSuccess: (_, variables) => { - setStatus((prevStatus) => - prevStatus.map((item) => (item.integrationId === variables.integrationId ? { ...item, enabled: false } : item)), + onSettled: (_, error, { integrationId }) => { + setSummaries((prevSummaries) => + prevSummaries.map((data) => ({ + ...data, + summary: + data.integration.id === integrationId && data.summary + ? { ...data.summary, status: error ? "enabled" : "disabled" } + : data.summary, + })), ); }, }); const toggleDns = (integrationId: string) => { - const integrationStatus = status.find((item) => item.integrationId === integrationId); - if (integrationStatus?.enabled) { + const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId); + if (!integrationStatus?.summary?.status) return; + setSummaries((prevSummaries) => + prevSummaries.map((data) => ({ + ...data, + summary: + data.integration.id === integrationId && data.summary ? { ...data.summary, status: undefined } : data.summary, + })), + ); + if (integrationStatus.summary.status === "enabled") { disableDns({ integrationId, duration: 0 }); } else { enableDns({ integrationId }); } }; - const enabledIntegrations = integrationIds.filter((id) => status.find((item) => item.integrationId === id)?.enabled); - const disabledIntegrations = integrationIds.filter( - (id) => !status.find((item) => item.integrationId === id)?.enabled, + // 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(); + + // Timer modal setup + const [selectedIntegrationIds, setSelectedIntegrationIds] = useState([]); + const [opened, { close, open }] = useDisclosure(false); + + const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0; + + if (integrationIds.length === 0) { + throw new NoIntegrationSelectedError(); + } + return ( - - {options.showToggleAllButtons && ( - + + {controlAllButtonsVisible && ( + )} - - {data.map((integrationData) => ( - - ))} - + + + {summaries.map((summary) => ( + + ))} + + void; - status: { integrationId: string; enabled: boolean }[]; + data: RouterOutputs["widget"]["dnsHole"]["summary"][number]; setSelectedIntegrationIds: (integrationId: string[]) => void; open: () => void; t: TranslationFunction; } const ControlsCard: React.FC = ({ - integrationId, - integrationKind, + integrationsWithInteractions, toggleDns, - status, + data, setSelectedIntegrationIds, open, t, }) => { - const integrationStatus = status.find((item) => item.integrationId === integrationId); - const isEnabled = integrationStatus?.enabled ?? false; - const integrationDef = integrationKind === "piHole" ? integrationDefs.piHole : integrationDefs.adGuardHome; + // Independently determine connection status, current state and permissions + const isConnected = data.summary !== null && Math.abs(dayjs(data.timestamp).diff()) < 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; return ( - - - - - {integrationDef.name} - - toggleDns(integrationId)}> - - {t(`widget.dnsHoleControls.controls.${isEnabled ? "enabled" : "disabled"}`)} + + + + + + {data.integration.name} + + + toggleDns(data.integration.id)} + > + + ) + } + > + {t( + `widget.dnsHoleControls.controls.${ + !isConnected + ? "disconnected" + : typeof isEnabled === "undefined" + ? "processing" + : isEnabled + ? "enabled" + : "disabled" + }`, + )} { - setSelectedIntegrationIds([integrationId]); + setSelectedIntegrationIds([data.integration.id]); open(); }} > - + diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index da6e62659..b190b1b24 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -5,7 +5,9 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; -export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("dnsHoleControls", { +export const widgetKind = "dnsHoleControls"; + +export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, { icon: IconDeviceGamepad, options: optionsBuilder.from((factory) => ({ showToggleAllButtons: factory.switch({ diff --git a/packages/widgets/src/dns-hole/controls/serverData.ts b/packages/widgets/src/dns-hole/controls/serverData.ts index b9bfe541f..8212b9b9d 100644 --- a/packages/widgets/src/dns-hole/controls/serverData.ts +++ b/packages/widgets/src/dns-hole/controls/serverData.ts @@ -2,9 +2,10 @@ import { api } from "@homarr/api/server"; +import { widgetKind } from "."; import type { WidgetProps } from "../../definition"; -export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleControls">) { +export default async function getServerDataAsync({ integrationIds }: WidgetProps) { if (integrationIds.length === 0) { return { initialData: [], @@ -13,6 +14,7 @@ export default async function getServerDataAsync({ integrationIds }: WidgetProps try { const currentDns = await api.widget.dnsHole.summary({ + widgetKind, integrationIds, }); diff --git a/packages/widgets/src/dns-hole/summary/component.tsx b/packages/widgets/src/dns-hole/summary/component.tsx index 87b0e0a2b..708256629 100644 --- a/packages/widgets/src/dns-hole/summary/component.tsx +++ b/packages/widgets/src/dns-hole/summary/component.tsx @@ -1,18 +1,22 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { BoxProps } from "@mantine/core"; -import { Box, Card, Flex, Text } from "@mantine/core"; +import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react"; +import dayjs from "dayjs"; -import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; import { formatNumber } from "@homarr/common"; +import { integrationDefs } from "@homarr/definitions"; +import type { DnsHoleSummary } from "@homarr/integrations/types"; import type { stringOrTranslation, TranslationFunction } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; +import { widgetKind } from "."; import type { WidgetComponentProps, WidgetProps } from "../../definition"; import { NoIntegrationSelectedError } from "../../errors"; @@ -20,20 +24,65 @@ export default function DnsHoleSummaryWidget({ options, integrationIds, serverData, -}: WidgetComponentProps<"dnsHoleSummary">) { - const integrationId = integrationIds.at(0); +}: WidgetComponentProps) { + const [summaries, setSummaries] = useState(serverData?.initialData ?? []); - if (!integrationId) { + const t = useI18n(); + + clientApi.widget.dnsHole.subscribeToSummary.useSubscription( + { + widgetKind, + integrationIds, + }, + { + onData: (data) => { + setSummaries((prevSummaries) => + prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)), + ); + }, + }, + ); + + const data = useMemo( + () => + summaries + .filter( + ( + pair, + ): pair is { + integration: typeof pair.integration; + timestamp: typeof pair.timestamp; + summary: DnsHoleSummary; + } => pair.summary !== null && Math.abs(dayjs(pair.timestamp).diff()) < 30000, + ) + .flatMap(({ summary }) => summary), + [summaries, serverData], + ); + + if (integrationIds.length === 0) { throw new NoIntegrationSelectedError(); } - const data = useMemo(() => (serverData?.initialData ?? []).flatMap((summary) => summary.summary), [serverData]); - return ( - - {stats.map((item, index) => ( - - ))} + + {data.length > 0 ? ( + stats.map((item) => ( + + )) + ) : ( + + + {summaries.map(({ integration }) => ( + + + + ))} + + + {t("widget.dnsHoleSummary.error.integrationsDisconnected")} + + + )} ); } @@ -86,26 +135,26 @@ const stats = [ interface StatItem { icon: TablerIcon; - value: (x: RouterOutputs["widget"]["dnsHole"]["summary"][number]["summary"][], t: TranslationFunction) => string; + value: (x: DnsHoleSummary[], t: TranslationFunction) => string; label: stringOrTranslation; color: string; } interface StatCardProps { item: StatItem; - data: RouterOutputs["widget"]["dnsHole"]["summary"][number]["summary"][]; + data: DnsHoleSummary[]; usePiHoleColors: boolean; + t: TranslationFunction; } -const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => { +const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => { const { ref, height, width } = useElementSize(); const isLong = width > height + 20; - const t = useI18n(); return ( { direction={isLong ? "row" : "column"} style={{ containerType: "size" }} > - + { h="100%" gap="1cqmin" > - + {item.value(data, t)} {item.label && ( - + {translateIfNecessary(t, item.label)} )} diff --git a/packages/widgets/src/dns-hole/summary/index.ts b/packages/widgets/src/dns-hole/summary/index.ts index bb4d280af..d1c3ff18a 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -5,7 +5,9 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; -export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("dnsHoleSummary", { +export const widgetKind = "dnsHoleSummary"; + +export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, { icon: IconAd, options: optionsBuilder.from((factory) => ({ usePiHoleColors: factory.switch({ diff --git a/packages/widgets/src/dns-hole/summary/serverData.ts b/packages/widgets/src/dns-hole/summary/serverData.ts index 9c31553e5..8212b9b9d 100644 --- a/packages/widgets/src/dns-hole/summary/serverData.ts +++ b/packages/widgets/src/dns-hole/summary/serverData.ts @@ -2,9 +2,10 @@ import { api } from "@homarr/api/server"; +import { widgetKind } from "."; import type { WidgetProps } from "../../definition"; -export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) { +export default async function getServerDataAsync({ integrationIds }: WidgetProps) { if (integrationIds.length === 0) { return { initialData: [], @@ -13,6 +14,7 @@ export default async function getServerDataAsync({ integrationIds }: WidgetProps try { const currentDns = await api.widget.dnsHole.summary({ + widgetKind, integrationIds, }); diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index afd8be9cc..982f55109 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -40,7 +40,9 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { humanFileSize } from "@homarr/common"; +import type { Modify } from "@homarr/common/types"; import type { Integration } from "@homarr/db/schema/sqlite"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import type { DownloadClientJobsAndStatus, @@ -97,7 +99,7 @@ export default function DownloadClientsWidget({ ); const [currentItems, currentItemsHandlers] = useListState<{ - integration: Integration; + integration: Modify }>; timestamp: Date; data: DownloadClientJobsAndStatus | null; }>( @@ -173,8 +175,13 @@ export default function DownloadClientsWidget({ .filter(({ integration }) => integrationIds.includes(integration.id)) //Removing any integration with no data associated .filter( - (pair): pair is { integration: Integration; timestamp: Date; data: DownloadClientJobsAndStatus } => - pair.data != null, + ( + pair, + ): pair is { + integration: typeof pair.integration; + timestamp: typeof pair.timestamp; + data: DownloadClientJobsAndStatus; + } => pair.data != null, ) //Construct normalized items list .flatMap((pair) => diff --git a/packages/widgets/src/widgets-common.css b/packages/widgets/src/widgets-common.css index 0418074fe..02e57f750 100644 --- a/packages/widgets/src/widgets-common.css +++ b/packages/widgets/src/widgets-common.css @@ -34,3 +34,42 @@ min-height: var(--sortButtonSize); } } + +/*Make background of component different on hover, depending on base var*/ +.hoverable-component { + &:hover { + background-color: rgb(from var(--background-color) calc(r + 10) calc(g + 10) calc(b + 10) / var(--opacity)); + } +} + +/*Make background of component different on click, depending on base var, inverse of hover*/ +.clickable-component { + &:active { + background-color: rgb(from var(--background-color) calc(r - 10) calc(g - 10) calc(b - 10) / var(--opacity)); + } +} + +/*FadingGlowing effect for text that updates, add className and put the updating value as key*/ +@keyframes glow { + from { + text-shadow: 0 0 var(--glow-size) var(--mantine-color-text); + } + to { + text-shadow: none; + } +} + +.text-flash { + animation: glow 1s ease-in-out; +} + +/*To apply to any ScrollArea that we want to flex. Same weird workaround as before*/ +.flexed-scroll-area { + height: 100%; + .mantine-ScrollArea-viewport { + & div[style="min-width: 100%; display: table;"] { + display: flex !important; + height: 100%; + } + } +}