mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-06 22:59:20 +01:00
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 <meierschlumpf@gmail.com>
This commit is contained in:
@@ -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<DnsHoleSummary>(`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<DnsHoleSummary>(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<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
|
||||
timestamp: Date;
|
||||
summary: DnsHoleSummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(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 } }) => {
|
||||
|
||||
@@ -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<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
||||
timestamp: Date;
|
||||
data: DownloadClientJobsAndStatus;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
packages/cron-jobs/src/jobs/integrations/dns-hole.ts
Normal file
28
packages/cron-jobs/src/jobs/integrations/dns-hole.ts
Normal file
@@ -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<DnsHoleSummary>(itemForIntegration.kind, integration.id);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
})
|
||||
.catch((error) => logger.error(`Could not retrieve data for ${integration.name}: "${error}"`));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface DnsHoleSummary {
|
||||
status: "enabled" | "disabled";
|
||||
status?: "enabled" | "disabled";
|
||||
domainsBeingBlocked: number;
|
||||
adsBlockedToday: number;
|
||||
adsBlockedTodayPercentage: number;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
export default function DnsHoleControlsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
serverData,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
// 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<string[]>([]);
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
|
||||
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
throw new NoIntegrationSelectedError();
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex h="100%" direction="column" gap={0} p="2.5cqmin">
|
||||
{options.showToggleAllButtons && (
|
||||
<Flex m="2.5cqmin" gap="2.5cqmin">
|
||||
<Flex
|
||||
className="dns-hole-controls-stack"
|
||||
h="100%"
|
||||
direction="column"
|
||||
p="2.5cqmin"
|
||||
gap="2.5cqmin"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
{controlAllButtonsVisible && (
|
||||
<Flex className="dns-hole-controls-buttons" gap="2.5cqmin">
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
||||
<Button
|
||||
onClick={() => disabledIntegrations.forEach((integrationId) => enableDns({ integrationId }))}
|
||||
disabled={disabledIntegrations.length === 0}
|
||||
className="dns-hole-controls-enable-all-button"
|
||||
onClick={() => integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
|
||||
disabled={integrationsSummaries.disabled.length === 0}
|
||||
variant="light"
|
||||
color="green"
|
||||
fullWidth
|
||||
h="2rem"
|
||||
h="fit-content"
|
||||
p="1.25cqmin"
|
||||
bd={0}
|
||||
radius="2.5cqmin"
|
||||
flex={1}
|
||||
>
|
||||
<IconPlayerPlay size={20} />
|
||||
<IconPlayerPlay
|
||||
className="dns-hole-controls-enable-all-icon"
|
||||
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
|
||||
<Button
|
||||
className="dns-hole-controls-timer-all-button"
|
||||
onClick={() => {
|
||||
setSelectedIntegrationIds(enabledIntegrations);
|
||||
setSelectedIntegrationIds(integrationsSummaries.enabled);
|
||||
open();
|
||||
}}
|
||||
disabled={enabledIntegrations.length === 0}
|
||||
disabled={integrationsSummaries.enabled.length === 0}
|
||||
variant="light"
|
||||
color="yellow"
|
||||
fullWidth
|
||||
h="2rem"
|
||||
h="fit-content"
|
||||
p="1.25cqmin"
|
||||
bd={0}
|
||||
radius="2.5cqmin"
|
||||
flex={1}
|
||||
>
|
||||
<IconClockPause size={20} />
|
||||
<IconClockPause
|
||||
className="dns-hole-controls-timer-all-icon"
|
||||
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
|
||||
<Button
|
||||
onClick={() => enabledIntegrations.forEach((integrationId) => disableDns({ integrationId, duration: 0 }))}
|
||||
disabled={enabledIntegrations.length === 0}
|
||||
className="dns-hole-controls-disable-all-button"
|
||||
onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
|
||||
disabled={integrationsSummaries.enabled.length === 0}
|
||||
variant="light"
|
||||
color="red"
|
||||
fullWidth
|
||||
h="2rem"
|
||||
h="fit-content"
|
||||
p="1.25cqmin"
|
||||
bd={0}
|
||||
radius="2.5cqmin"
|
||||
flex={1}
|
||||
>
|
||||
<IconPlayerStop size={20} />
|
||||
<IconPlayerStop
|
||||
className="dns-hole-controls-disable-all-icon"
|
||||
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Stack m="2.5cqmin" gap="2.5cqmin" flex={1} justify={options.showToggleAllButtons ? "flex-end" : "space-evenly"}>
|
||||
{data.map((integrationData) => (
|
||||
<ControlsCard
|
||||
key={integrationData.integrationId}
|
||||
integrationId={integrationData.integrationId}
|
||||
integrationKind={integrationData.integrationKind}
|
||||
toggleDns={toggleDns}
|
||||
status={status}
|
||||
setSelectedIntegrationIds={setSelectedIntegrationIds}
|
||||
open={open}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<ScrollArea className="dns-hole-controls-integration-list-scroll-area flexed-scroll-area">
|
||||
<Stack
|
||||
className="dns-hole-controls-integration-list"
|
||||
gap="2.5cqmin"
|
||||
flex={1}
|
||||
justify={controlAllButtonsVisible ? "flex-end" : "space-evenly"}
|
||||
>
|
||||
{summaries.map((summary) => (
|
||||
<ControlsCard
|
||||
key={summary.integration.id}
|
||||
integrationsWithInteractions={integrationsWithInteractions}
|
||||
toggleDns={toggleDns}
|
||||
data={summary}
|
||||
setSelectedIntegrationIds={setSelectedIntegrationIds}
|
||||
open={open}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<TimerModal
|
||||
opened={opened}
|
||||
@@ -147,52 +235,109 @@ export default function DnsHoleControlsWidget({ options, integrationIds }: Widge
|
||||
}
|
||||
|
||||
interface ControlsCardProps {
|
||||
integrationId: string;
|
||||
integrationKind: string;
|
||||
integrationsWithInteractions: string[];
|
||||
toggleDns: (integrationId: string) => void;
|
||||
status: { integrationId: string; enabled: boolean }[];
|
||||
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
|
||||
setSelectedIntegrationIds: (integrationId: string[]) => void;
|
||||
open: () => void;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
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 (
|
||||
<Card key={integrationId} withBorder p="2.5cqmin" radius="2.5cqmin">
|
||||
<Flex justify="space-between" align="center" direction="row" m="2.5cqmin">
|
||||
<Image src={integrationDef.iconUrl} width="50cqmin" height="50cqmin" fit="contain" />
|
||||
<Flex direction="column">
|
||||
<Text>{integrationDef.name}</Text>
|
||||
<Flex direction="row" gap="2cqmin">
|
||||
<UnstyledButton onClick={() => toggleDns(integrationId)}>
|
||||
<Badge variant="dot" color={dnsLightStatus(isEnabled)}>
|
||||
{t(`widget.dnsHoleControls.controls.${isEnabled ? "enabled" : "disabled"}`)}
|
||||
<Card
|
||||
className={`dns-hole-controls-integration-item-outer-shell dns-hole-controls-integration-item-${data.integration.id} dns-hole-controls-integration-item-${data.integration.name}`}
|
||||
key={data.integration.id}
|
||||
withBorder
|
||||
p="2.5cqmin"
|
||||
radius="2.5cqmin"
|
||||
>
|
||||
<Flex className="dns-hole-controls-item-container" gap="4cqmin" align="center" direction="row">
|
||||
<Image
|
||||
className="dns-hole-controls-item-icon"
|
||||
src={integrationDefs[data.integration.kind].iconUrl}
|
||||
w="20cqmin"
|
||||
h="20cqmin"
|
||||
fit="contain"
|
||||
style={{ filter: !isConnected ? "grayscale(100%)" : undefined }}
|
||||
/>
|
||||
<Flex className="dns-hole-controls-item-data-stack" direction="column" gap="1.5cqmin">
|
||||
<Text className="dns-hole-controls-item-integration-name" fz="7cqmin">
|
||||
{data.integration.name}
|
||||
</Text>
|
||||
<Flex className="dns-hole-controls-item-controls" direction="row" gap="1.5cqmin">
|
||||
<UnstyledButton
|
||||
className="dns-hole-controls-item-toggle-button"
|
||||
disabled={!controlEnabled}
|
||||
display="contents"
|
||||
style={{ cursor: controlEnabled ? "pointer" : "default" }}
|
||||
onClick={() => toggleDns(data.integration.id)}
|
||||
>
|
||||
<Badge
|
||||
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
|
||||
bd="0.1cqmin solid var(--border-color)"
|
||||
px="2.5cqmin"
|
||||
h="7.5cqmin"
|
||||
fz="4.5cqmin"
|
||||
lts="0.1cqmin"
|
||||
color="var(--background-color)"
|
||||
c="var(--mantine-color-text)"
|
||||
styles={{ section: { marginInlineEnd: "2.5cqmin" } }}
|
||||
leftSection={
|
||||
isConnected && (
|
||||
<IconCircleFilled
|
||||
className="dns-hole-controls-item-status-icon"
|
||||
color={dnsLightStatus(isEnabled)}
|
||||
style={{ height: "3.5cqmin", width: "3.5cqmin" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`widget.dnsHoleControls.controls.${
|
||||
!isConnected
|
||||
? "disconnected"
|
||||
: typeof isEnabled === "undefined"
|
||||
? "processing"
|
||||
: isEnabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`,
|
||||
)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
<ActionIcon
|
||||
disabled={!isEnabled}
|
||||
size={20}
|
||||
radius="xl"
|
||||
top="2.67px"
|
||||
variant="default"
|
||||
className="dns-hole-controls-item-timer-button"
|
||||
display={isInteractPermitted ? undefined : "none"}
|
||||
disabled={!controlEnabled || !isEnabled}
|
||||
color="yellow"
|
||||
size="fit-content"
|
||||
radius="999px 999px 0px 999px"
|
||||
bd={0}
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setSelectedIntegrationIds([integrationId]);
|
||||
setSelectedIntegrationIds([data.integration.id]);
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<IconClockPause size={20} color="red" />
|
||||
<IconClockPause
|
||||
className="dns-hole-controls-item-timer-icon"
|
||||
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<typeof widgetKind>) {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof widgetKind>) {
|
||||
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 (
|
||||
<Box h="100%" {...boxPropsByLayout(options.layout)}>
|
||||
{stats.map((item, index) => (
|
||||
<StatCard key={index} item={item} usePiHoleColors={options.usePiHoleColors} data={data} />
|
||||
))}
|
||||
<Box h="100%" {...boxPropsByLayout(options.layout)} p="2cqmin">
|
||||
{data.length > 0 ? (
|
||||
stats.map((item) => (
|
||||
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
|
||||
))
|
||||
) : (
|
||||
<Stack h="100%" w="100%" justify="center" align="center" gap="2.5cqmin" p="2.5cqmin">
|
||||
<AvatarGroup spacing="10cqmin">
|
||||
{summaries.map(({ integration }) => (
|
||||
<Tooltip key={integration.id} label={integration.name}>
|
||||
<Avatar h="35cqmin" w="35cqmin" src={integrationDefs[integration.kind].iconUrl} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Text fz="10cqmin" ta="center">
|
||||
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card
|
||||
ref={ref}
|
||||
className="summary-card"
|
||||
m="2.5cqmin"
|
||||
m="2cqmin"
|
||||
p="2.5cqmin"
|
||||
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
|
||||
style={{
|
||||
@@ -122,7 +171,7 @@ const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
|
||||
direction={isLong ? "row" : "column"}
|
||||
style={{ containerType: "size" }}
|
||||
>
|
||||
<item.icon className="summary-card-icon" size="50cqmin" style={{ margin: "2cqmin" }} />
|
||||
<item.icon className="summary-card-icon" size="40cqmin" style={{ margin: "2.5cqmin" }} />
|
||||
<Flex
|
||||
className="summary-card-texts"
|
||||
justify="center"
|
||||
@@ -134,11 +183,18 @@ const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
|
||||
h="100%"
|
||||
gap="1cqmin"
|
||||
>
|
||||
<Text className="summary-card-value" ta="center" size="25cqmin" fw="bold">
|
||||
<Text
|
||||
key={item.value(data, t)}
|
||||
className="summary-card-value text-flash"
|
||||
ta="center"
|
||||
size="20cqmin"
|
||||
fw="bold"
|
||||
style={{ "--glow-size": "2.5cqmin" }}
|
||||
>
|
||||
{item.value(data, t)}
|
||||
</Text>
|
||||
{item.label && (
|
||||
<Text className="summary-card-label" ta="center" size="17.5cqmin">
|
||||
<Text className="summary-card-label" ta="center" size="15cqmin">
|
||||
{translateIfNecessary(t, item.label)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<typeof widgetKind>) {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
||||
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) =>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user