diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 51207d148..ea6ebc375 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -5,28 +5,90 @@ import type { DnsHoleSummary } from "@homarr/integrations/types"; import { logger } from "@homarr/log"; import { createCacheChannel } from "@homarr/redis"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +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.unstable_concat(createOneIntegrationMiddleware("query", "piHole")).query(async ({ ctx }) => { - const cache = createCacheChannel(`dns-hole-summary:${ctx.integration.id}`); + summary: publicProcedure + .unstable_concat(createManyIntegrationMiddleware("query", "piHole", "adGuardHome")) + .query(async ({ 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 () => { + let client; + switch (integration.kind) { + case "piHole": + client = new PiHoleIntegration(integration); + break; + // case 'adGuardHome': + // client = new AdGuardHomeIntegration(integration); + // break; + default: + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Unsupported integration type: ${integration.kind}`, + }); + } - const { data } = await cache.consumeAsync(async () => { - const client = new PiHoleIntegration(ctx.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})`, + }); + }); + }); - 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 ${ctx.integration.name} (${ctx.integration.id})`, - }); - }); - }); + return { + integrationId: integration.id, + integrationKind: integration.kind, + summary: data, + }; + }), + ); + return results; + }), - return { - ...data, - integrationId: ctx.integration.id, - }; - }), + enable: publicProcedure + .unstable_concat(createOneIntegrationMiddleware("interact", "piHole", "adGuardHome")) + .mutation(async ({ ctx }) => { + let client; + switch (ctx.integration.kind) { + case "piHole": + client = new PiHoleIntegration(ctx.integration); + break; + // case 'adGuardHome': + // client = new AdGuardHomeIntegration(ctx.integration); + // break; + default: + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Unsupported integration type: ${ctx.integration.kind}`, + }); + } + await client.enableAsync(); + }), + + disable: publicProcedure + .input(controlsInputSchema) + .unstable_concat(createOneIntegrationMiddleware("interact", "piHole", "adGuardHome")) + .mutation(async ({ ctx, input }) => { + let client; + switch (ctx.integration.kind) { + case "piHole": + client = new PiHoleIntegration(ctx.integration); + break; + // case 'adGuardHome': + // client = new AdGuardHomeIntegration(ctx.integration); + // break; + default: + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Unsupported integration type: ${ctx.integration.kind}`, + }); + } + await client.disableAsync(input.duration); + }), }); diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 9b50fa9dc..7682d5ab1 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -6,6 +6,7 @@ export const widgetKinds = [ "video", "notebook", "dnsHoleSummary", + "dnsHoleControls", "smartHome-entityState", "smartHome-executeAutomation", "mediaServer", 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 2295f9079..749360175 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,4 +1,5 @@ export interface DnsHoleSummary { + status: "enabled" | "disabled"; domainsBeingBlocked: number; adsBlockedToday: number; adsBlockedTodayPercentage: number; diff --git a/packages/integrations/src/pi-hole/pi-hole-integration.ts b/packages/integrations/src/pi-hole/pi-hole-integration.ts index a89af7c9a..b1f25aa6b 100644 --- a/packages/integrations/src/pi-hole/pi-hole-integration.ts +++ b/packages/integrations/src/pi-hole/pi-hole-integration.ts @@ -23,6 +23,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte } return { + status: result.data.status, adsBlockedToday: result.data.ads_blocked_today, adsBlockedTodayPercentage: result.data.ads_percentage_today, domainsBeingBlocked: result.data.domains_being_blocked, @@ -49,4 +50,25 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte }, }); } + + public async enableAsync(): Promise { + const apiKey = super.getSecretValue("apiKey"); + const response = await fetch(`${this.integration.url}/admin/api.php?enable&auth=${apiKey}`); + if (!response.ok) { + throw new Error( + `Failed to enable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, + ); + } + } + + public async disableAsync(duration?: number): Promise { + const apiKey = super.getSecretValue("apiKey"); + const url = `${this.integration.url}/admin/api.php?disable${duration ? `=${duration}` : ""}&auth=${apiKey}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to disable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, + ); + } + } } diff --git a/packages/integrations/src/pi-hole/pi-hole-types.ts b/packages/integrations/src/pi-hole/pi-hole-types.ts index 6b2a28a95..234eb511c 100644 --- a/packages/integrations/src/pi-hole/pi-hole-types.ts +++ b/packages/integrations/src/pi-hole/pi-hole-types.ts @@ -7,3 +7,7 @@ export const summaryResponseSchema = z.object({ dns_queries_today: z.number(), ads_percentage_today: z.number(), }); + +export const controlsInputSchema = z.object({ + duration: z.number().optional(), +}); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 9b8cb53ab..836674744 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -558,7 +558,7 @@ export default { }, multiText: { placeholder: "Add more values", - addLabel: `Add {value}`, + addLabel: "Add {value}", }, select: { placeholder: "Pick value", @@ -763,6 +763,43 @@ export default { domainsBeingBlocked: "Domains on blocklist", }, }, + dnsHoleControls: { + name: "DNS Hole Controls", + description: "Control PiHole or AdGuard from your dashboard", + option: { + layout: { + label: "Layout", + option: { + row: { + label: "Horizontal", + }, + column: { + label: "Vertical", + }, + grid: { + label: "Grid", + }, + }, + }, + showToggleAllButtons: { + label: "Show Toggle All Buttons", + }, + }, + error: { + internalServerError: "Failed to control DNS Hole", + }, + controls: { + enableAll: "Enable All", + disableAll: "Disable All", + setTimer: "Set Timer", + set: "Set", + enabled: "Enabled", + disabled: "Disabled", + hours: "Hours", + minutes: "Minutes", + unlimited: "Leave blank to unlimited", + }, + }, clock: { name: "Date and time", description: "Displays the current date and time.", diff --git a/packages/widgets/src/dns-hole/controls/TimerModal.tsx b/packages/widgets/src/dns-hole/controls/TimerModal.tsx new file mode 100644 index 000000000..71cb09c10 --- /dev/null +++ b/packages/widgets/src/dns-hole/controls/TimerModal.tsx @@ -0,0 +1,105 @@ +import { useRef, useState } from "react"; +import type { NumberInputHandlers } from "@mantine/core"; +import { ActionIcon, Button, Flex, Group, Modal, NumberInput, rem, Stack, Text } from "@mantine/core"; +import { IconClockPause } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface TimerModalProps { + opened: boolean; + close: () => void; + integrationIds: string[]; + disableDns: (data: { duration: number; integrationId: string }) => void; +} + +const TimerModal = ({ opened, close, integrationIds, disableDns }: TimerModalProps) => { + const t = useI18n(); + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + const hoursHandlers = useRef(); + const minutesHandlers = useRef(); + + const handleSetTimer = () => { + const duration = hours * 3600 + minutes * 60; + integrationIds.forEach((integrationId) => { + disableDns({ duration, integrationId }); + }); + setHours(0); + setMinutes(0); + close(); + }; + + return ( + { + close(); + setHours(0); + setMinutes(0); + }} + title={t("widget.dnsHoleControls.controls.setTimer")} + > + + + + {t("widget.dnsHoleControls.controls.hours")} + hoursHandlers.current?.decrement()}> + – + + setHours(Number(val))} + handlersRef={hoursHandlers} + max={999} + min={0} + step={1} + styles={{ input: { width: rem(54), textAlign: "center" } }} + /> + hoursHandlers.current?.increment()}> + + + + + + {t("widget.dnsHoleControls.controls.minutes")} + minutesHandlers.current?.decrement()}> + – + + setMinutes(Number(val))} + handlersRef={minutesHandlers} + max={59} + min={0} + step={1} + styles={{ input: { width: rem(54), textAlign: "center" } }} + /> + minutesHandlers.current?.increment()}> + + + + + + + {t("widget.dnsHoleControls.controls.unlimited")} + + + + + ); +}; + +export default TimerModal; diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx new file mode 100644 index 000000000..05a56b4a4 --- /dev/null +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { BoxProps } from "@mantine/core"; +import { ActionIcon, Badge, Box, Button, Card, Flex, Image, Tooltip, UnstyledButton } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { integrationDefs } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps, WidgetProps } from "../../definition"; +import { NoIntegrationSelectedError } from "../../errors"; +import TimerModal from "./TimerModal"; + +const dnsLightStatus = (enabled: boolean): "green" | "red" => (enabled ? "green" : "red"); + +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 [opened, { close, open }] = useDisclosure(false); + + const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery( + { + integrationIds, + }, + { + refetchOnMount: false, + retry: false, + }, + ); + + useEffect(() => { + const newStatus = data.map((integrationData) => ({ + integrationId: integrationData.integrationId, + enabled: integrationData.summary.status === "enabled", + })); + setStatus(newStatus); + }, [data]); + + const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({ + onSuccess: (_, variables) => { + setStatus((prevStatus) => + prevStatus.map((item) => (item.integrationId === variables.integrationId ? { ...item, enabled: true } : item)), + ); + }, + }); + const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({ + onSuccess: (_, variables) => { + setStatus((prevStatus) => + prevStatus.map((item) => (item.integrationId === variables.integrationId ? { ...item, enabled: false } : item)), + ); + }, + }); + const toggleDns = (integrationId: string) => { + const integrationStatus = status.find((item) => item.integrationId === integrationId); + if (integrationStatus?.enabled) { + disableDns({ integrationId, duration: 0 }); + } else { + enableDns({ integrationId }); + } + }; + + const allEnabled = status.every((item) => item.enabled); + const allDisabled = status.every((item) => !item.enabled); + + return ( + + {options.showToggleAllButtons && ( + + + + + + + + + + + + + + )} + + {data.map((integrationData) => + ControlsCard(integrationData.integrationId, integrationData.integrationKind, toggleDns, status, open, t), + )} + + + + ); +} + +const ControlsCard = ( + integrationId: string, + integrationKind: string, + toggleDns: (integrationId: string) => void, + status: { integrationId: string; enabled: boolean }[], + open: () => void, + t: ReturnType, +) => { + const integrationStatus = status.find((item) => item.integrationId === integrationId); + const isEnabled = integrationStatus?.enabled ?? false; + const integrationDef = integrationKind === "piHole" ? integrationDefs.piHole : integrationDefs.adGuardHome; + + return ( + + + + + + + {integrationDef.name} + + toggleDns(integrationId)}> + + {isEnabled + ? t("widget.dnsHoleControls.controls.enabled") + : t("widget.dnsHoleControls.controls.disabled")} + + + + + + + + + + ); +}; + +const boxPropsByLayout = (layout: WidgetProps<"dnsHoleControls">["options"]["layout"]): BoxProps => { + if (layout === "grid") { + return { + display: "grid", + style: { + gridTemplateColumns: "1fr 1fr", + gridTemplateRows: "1fr 1fr", + }, + }; + } + + return { + display: "flex", + style: { + flexDirection: layout, + }, + }; +}; diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts new file mode 100644 index 000000000..785bf5ce4 --- /dev/null +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -0,0 +1,29 @@ +import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../../definition"; +import { optionsBuilder } from "../../options"; + +export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("dnsHoleControls", { + icon: IconDeviceGamepad, + options: optionsBuilder.from((factory) => ({ + showToggleAllButtons: factory.switch({ + defaultValue: true, + }), + layout: factory.select({ + options: (["grid", "row", "column"] as const).map((value) => ({ + value, + label: (t) => t(`widget.dnsHoleControls.option.layout.option.${value}.label`), + })), + defaultValue: "grid", + }), + })), + supportedIntegrations: ["piHole", "adGuardHome"], + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.dnsHoleControls.error.internalServerError"), + }, + }, +}) + .withServerData(() => import("./serverData")) + .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/dns-hole/controls/serverData.ts b/packages/widgets/src/dns-hole/controls/serverData.ts new file mode 100644 index 000000000..e70a0ae7a --- /dev/null +++ b/packages/widgets/src/dns-hole/controls/serverData.ts @@ -0,0 +1,27 @@ +"use server"; + +import { api } from "@homarr/api/server"; + +import type { WidgetProps } from "../../definition"; + +export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) { + if (integrationIds.length === 0) { + return { + initialData: [], + }; + } + + try { + const currentDns = await api.widget.dnsHole.summary({ + integrationIds, + }); + + return { + initialData: currentDns, + }; + } catch (error) { + return { + initialData: undefined, + }; + } +} diff --git a/packages/widgets/src/dns-hole/summary/component.tsx b/packages/widgets/src/dns-hole/summary/component.tsx index 8eb1a8a02..457ee9072 100644 --- a/packages/widgets/src/dns-hole/summary/component.tsx +++ b/packages/widgets/src/dns-hole/summary/component.tsx @@ -25,7 +25,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery( { - integrationId, + integrationIds, }, { refetchOnMount: false, @@ -36,7 +36,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget return ( {stats.map((item, index) => ( - + ))} ); diff --git a/packages/widgets/src/dns-hole/summary/index.ts b/packages/widgets/src/dns-hole/summary/index.ts index fc8610aba..9267f8462 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -17,7 +17,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef defaultValue: "grid", }), })), - supportedIntegrations: ["piHole"], + supportedIntegrations: ["piHole", "adGuardHome"], errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/dns-hole/summary/serverData.ts b/packages/widgets/src/dns-hole/summary/serverData.ts index 74cea93a4..5f7ce758e 100644 --- a/packages/widgets/src/dns-hole/summary/serverData.ts +++ b/packages/widgets/src/dns-hole/summary/serverData.ts @@ -5,16 +5,19 @@ import { api } from "@homarr/api/server"; import type { WidgetProps } from "../../definition"; export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) { - const integrationId = integrationIds.at(0); - if (!integrationId) return { initialData: undefined }; + if (integrationIds.length === 0) { + return { + initialData: [], + }; + } try { - const data = await api.widget.dnsHole.summary({ - integrationId, + const currentDns = await api.widget.dnsHole.summary({ + integrationIds, }); return { - initialData: data, + initialData: currentDns, }; } catch { return { diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 862fc3d20..cbc229559 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -9,6 +9,7 @@ import * as app from "./app"; import * as calendar from "./calendar"; import * as clock from "./clock"; import type { WidgetComponentProps } from "./definition"; +import * as dnsHoleControls from "./dns-hole/controls"; import * as dnsHoleSummary from "./dns-hole/summary"; import * as iframe from "./iframe"; import type { WidgetImportRecord } from "./import"; @@ -22,9 +23,11 @@ import * as weather from "./weather"; export { reduceWidgetOptionsWithDefaultValues } from "./options"; +export type { WidgetDefinition } from "./definition"; export { WidgetEditModal } from "./modals/widget-edit-modal"; export { useServerDataFor } from "./server/provider"; export { GlobalItemServerDataRunner } from "./server/runner"; +export type { WidgetComponentProps }; export const widgetImports = { clock, @@ -34,6 +37,7 @@ export const widgetImports = { iframe, video, dnsHoleSummary, + dnsHoleControls, "smartHome-entityState": smartHomeEntityState, "smartHome-executeAutomation": smartHomeExecuteAutomation, mediaServer, @@ -43,8 +47,6 @@ export const widgetImports = { export type WidgetImports = typeof widgetImports; export type WidgetImportKey = keyof WidgetImports; -export type { WidgetComponentProps }; -export type { WidgetDefinition } from "./definition"; const loadedComponents = new Map>>();