feat: dnshole controls widget (#867)
* feat: dnshole controls widget * feat: add duration and timer modal * fix: code improvment * fix: add support for many integrations * fix: add support for more integrations * fix: move ControlsCard outside of main component * fix: deepsource
This commit is contained in:
@@ -5,28 +5,90 @@ import type { DnsHoleSummary } from "@homarr/integrations/types";
|
|||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { createCacheChannel } from "@homarr/redis";
|
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";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const dnsHoleRouter = createTRPCRouter({
|
export const dnsHoleRouter = createTRPCRouter({
|
||||||
summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("query", "piHole")).query(async ({ ctx }) => {
|
summary: publicProcedure
|
||||||
const cache = createCacheChannel<DnsHoleSummary>(`dns-hole-summary:${ctx.integration.id}`);
|
.unstable_concat(createManyIntegrationMiddleware("query", "piHole", "adGuardHome"))
|
||||||
|
.query(async ({ 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 () => {
|
||||||
|
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 () => {
|
return await client.getSummaryAsync().catch((err) => {
|
||||||
const client = new PiHoleIntegration(ctx.integration);
|
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) => {
|
return {
|
||||||
logger.error("dns-hole router - ", err);
|
integrationId: integration.id,
|
||||||
throw new TRPCError({
|
integrationKind: integration.kind,
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
summary: data,
|
||||||
message: `Failed to fetch DNS Hole summary for ${ctx.integration.name} (${ctx.integration.id})`,
|
};
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
});
|
return results;
|
||||||
|
}),
|
||||||
|
|
||||||
return {
|
enable: publicProcedure
|
||||||
...data,
|
.unstable_concat(createOneIntegrationMiddleware("interact", "piHole", "adGuardHome"))
|
||||||
integrationId: ctx.integration.id,
|
.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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const widgetKinds = [
|
|||||||
"video",
|
"video",
|
||||||
"notebook",
|
"notebook",
|
||||||
"dnsHoleSummary",
|
"dnsHoleSummary",
|
||||||
|
"dnsHoleControls",
|
||||||
"smartHome-entityState",
|
"smartHome-entityState",
|
||||||
"smartHome-executeAutomation",
|
"smartHome-executeAutomation",
|
||||||
"mediaServer",
|
"mediaServer",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export interface DnsHoleSummary {
|
export interface DnsHoleSummary {
|
||||||
|
status: "enabled" | "disabled";
|
||||||
domainsBeingBlocked: number;
|
domainsBeingBlocked: number;
|
||||||
adsBlockedToday: number;
|
adsBlockedToday: number;
|
||||||
adsBlockedTodayPercentage: number;
|
adsBlockedTodayPercentage: number;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
status: result.data.status,
|
||||||
adsBlockedToday: result.data.ads_blocked_today,
|
adsBlockedToday: result.data.ads_blocked_today,
|
||||||
adsBlockedTodayPercentage: result.data.ads_percentage_today,
|
adsBlockedTodayPercentage: result.data.ads_percentage_today,
|
||||||
domainsBeingBlocked: result.data.domains_being_blocked,
|
domainsBeingBlocked: result.data.domains_being_blocked,
|
||||||
@@ -49,4 +50,25 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async enableAsync(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ export const summaryResponseSchema = z.object({
|
|||||||
dns_queries_today: z.number(),
|
dns_queries_today: z.number(),
|
||||||
ads_percentage_today: z.number(),
|
ads_percentage_today: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const controlsInputSchema = z.object({
|
||||||
|
duration: z.number().optional(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -558,7 +558,7 @@ export default {
|
|||||||
},
|
},
|
||||||
multiText: {
|
multiText: {
|
||||||
placeholder: "Add more values",
|
placeholder: "Add more values",
|
||||||
addLabel: `Add {value}`,
|
addLabel: "Add {value}",
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
placeholder: "Pick value",
|
placeholder: "Pick value",
|
||||||
@@ -763,6 +763,43 @@ export default {
|
|||||||
domainsBeingBlocked: "Domains on blocklist",
|
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: {
|
clock: {
|
||||||
name: "Date and time",
|
name: "Date and time",
|
||||||
description: "Displays the current date and time.",
|
description: "Displays the current date and time.",
|
||||||
|
|||||||
@@ -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<NumberInputHandlers>();
|
||||||
|
const minutesHandlers = useRef<NumberInputHandlers>();
|
||||||
|
|
||||||
|
const handleSetTimer = () => {
|
||||||
|
const duration = hours * 3600 + minutes * 60;
|
||||||
|
integrationIds.forEach((integrationId) => {
|
||||||
|
disableDns({ duration, integrationId });
|
||||||
|
});
|
||||||
|
setHours(0);
|
||||||
|
setMinutes(0);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
withinPortal
|
||||||
|
radius="lg"
|
||||||
|
shadow="sm"
|
||||||
|
size="sm"
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => {
|
||||||
|
close();
|
||||||
|
setHours(0);
|
||||||
|
setMinutes(0);
|
||||||
|
}}
|
||||||
|
title={t("widget.dnsHoleControls.controls.setTimer")}
|
||||||
|
>
|
||||||
|
<Flex direction="column" align="center" justify="center">
|
||||||
|
<Stack align="flex-end">
|
||||||
|
<Group>
|
||||||
|
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
|
||||||
|
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
|
||||||
|
–
|
||||||
|
</ActionIcon>
|
||||||
|
<NumberInput
|
||||||
|
hideControls
|
||||||
|
value={hours}
|
||||||
|
onChange={(val) => setHours(Number(val))}
|
||||||
|
handlersRef={hoursHandlers}
|
||||||
|
max={999}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
styles={{ input: { width: rem(54), textAlign: "center" } }}
|
||||||
|
/>
|
||||||
|
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
|
||||||
|
+
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
|
||||||
|
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
|
||||||
|
–
|
||||||
|
</ActionIcon>
|
||||||
|
<NumberInput
|
||||||
|
hideControls
|
||||||
|
value={minutes}
|
||||||
|
onChange={(val) => setMinutes(Number(val))}
|
||||||
|
handlersRef={minutesHandlers}
|
||||||
|
max={59}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
styles={{ input: { width: rem(54), textAlign: "center" } }}
|
||||||
|
/>
|
||||||
|
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
|
||||||
|
+
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
<Text ta="center" c="dimmed" my={5}>
|
||||||
|
{t("widget.dnsHoleControls.controls.unlimited")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconClockPause size={20} />}
|
||||||
|
h="2rem"
|
||||||
|
w="12rem"
|
||||||
|
onClick={handleSetTimer}
|
||||||
|
>
|
||||||
|
{t("widget.dnsHoleControls.controls.set")}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimerModal;
|
||||||
@@ -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 (
|
||||||
|
<Box h="100%" {...boxPropsByLayout(options.layout)}>
|
||||||
|
{options.showToggleAllButtons && (
|
||||||
|
<Flex gap="xs" m="2.5cqmin" p="2.5cqmin">
|
||||||
|
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
integrationIds.forEach((integrationId) => enableDns({ integrationId }));
|
||||||
|
}}
|
||||||
|
disabled={allEnabled}
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
fullWidth
|
||||||
|
h="2rem"
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={20} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
|
||||||
|
<Button onClick={open} disabled={allDisabled} variant="light" color="yellow" fullWidth h="2rem">
|
||||||
|
<IconClockPause size={20} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
integrationIds.forEach((integrationId) => disableDns({ integrationId, duration: 0 }));
|
||||||
|
}}
|
||||||
|
disabled={allDisabled}
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
fullWidth
|
||||||
|
h="2rem"
|
||||||
|
>
|
||||||
|
<IconPlayerStop size={20} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.map((integrationData) =>
|
||||||
|
ControlsCard(integrationData.integrationId, integrationData.integrationKind, toggleDns, status, open, t),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TimerModal opened={opened} close={close} integrationIds={integrationIds} disableDns={disableDns} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlsCard = (
|
||||||
|
integrationId: string,
|
||||||
|
integrationKind: string,
|
||||||
|
toggleDns: (integrationId: string) => void,
|
||||||
|
status: { integrationId: string; enabled: boolean }[],
|
||||||
|
open: () => void,
|
||||||
|
t: ReturnType<typeof useI18n>,
|
||||||
|
) => {
|
||||||
|
const integrationStatus = status.find((item) => item.integrationId === integrationId);
|
||||||
|
const isEnabled = integrationStatus?.enabled ?? false;
|
||||||
|
const integrationDef = integrationKind === "piHole" ? integrationDefs.piHole : integrationDefs.adGuardHome;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={integrationId} withBorder m="2.5cqmin" p="2.5cqmin" radius="md">
|
||||||
|
<Flex>
|
||||||
|
<Box m="1.5cqmin" p="1.5cqmin">
|
||||||
|
<Image src={integrationDef.iconUrl} width={50} height={50} fit="contain" />
|
||||||
|
</Box>
|
||||||
|
<Flex direction="column" m="1.5cqmin" p="1.5cqmin" gap="1cqmin">
|
||||||
|
<Badge variant="default">{integrationDef.name}</Badge>
|
||||||
|
<Flex direction="row" gap="2cqmin">
|
||||||
|
<UnstyledButton onClick={() => toggleDns(integrationId)}>
|
||||||
|
<Badge variant="dot" color={dnsLightStatus(isEnabled)}>
|
||||||
|
{isEnabled
|
||||||
|
? t("widget.dnsHoleControls.controls.enabled")
|
||||||
|
: t("widget.dnsHoleControls.controls.disabled")}
|
||||||
|
</Badge>
|
||||||
|
</UnstyledButton>
|
||||||
|
<ActionIcon disabled={!isEnabled} size={20} radius="xl" top="2.67px" variant="default" onClick={open}>
|
||||||
|
<IconClockPause size={20} color="red" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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"));
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
|
|||||||
|
|
||||||
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
integrationId,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
@@ -36,7 +36,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
|
|||||||
return (
|
return (
|
||||||
<Box h="100%" {...boxPropsByLayout(options.layout)}>
|
<Box h="100%" {...boxPropsByLayout(options.layout)}>
|
||||||
{stats.map((item, index) => (
|
{stats.map((item, index) => (
|
||||||
<StatCard key={index} item={item} usePiHoleColors={options.usePiHoleColors} data={data} />
|
<StatCard key={index} item={item} usePiHoleColors={options.usePiHoleColors} data={data[0]?.summary} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
|||||||
defaultValue: "grid",
|
defaultValue: "grid",
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
supportedIntegrations: ["piHole"],
|
supportedIntegrations: ["piHole", "adGuardHome"],
|
||||||
errors: {
|
errors: {
|
||||||
INTERNAL_SERVER_ERROR: {
|
INTERNAL_SERVER_ERROR: {
|
||||||
icon: IconServerOff,
|
icon: IconServerOff,
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import { api } from "@homarr/api/server";
|
|||||||
import type { WidgetProps } from "../../definition";
|
import type { WidgetProps } from "../../definition";
|
||||||
|
|
||||||
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) {
|
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) {
|
||||||
const integrationId = integrationIds.at(0);
|
if (integrationIds.length === 0) {
|
||||||
if (!integrationId) return { initialData: undefined };
|
return {
|
||||||
|
initialData: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.widget.dnsHole.summary({
|
const currentDns = await api.widget.dnsHole.summary({
|
||||||
integrationId,
|
integrationIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialData: data,
|
initialData: currentDns,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as app from "./app";
|
|||||||
import * as calendar from "./calendar";
|
import * as calendar from "./calendar";
|
||||||
import * as clock from "./clock";
|
import * as clock from "./clock";
|
||||||
import type { WidgetComponentProps } from "./definition";
|
import type { WidgetComponentProps } from "./definition";
|
||||||
|
import * as dnsHoleControls from "./dns-hole/controls";
|
||||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||||
import * as iframe from "./iframe";
|
import * as iframe from "./iframe";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
@@ -22,9 +23,11 @@ import * as weather from "./weather";
|
|||||||
|
|
||||||
export { reduceWidgetOptionsWithDefaultValues } from "./options";
|
export { reduceWidgetOptionsWithDefaultValues } from "./options";
|
||||||
|
|
||||||
|
export type { WidgetDefinition } from "./definition";
|
||||||
export { WidgetEditModal } from "./modals/widget-edit-modal";
|
export { WidgetEditModal } from "./modals/widget-edit-modal";
|
||||||
export { useServerDataFor } from "./server/provider";
|
export { useServerDataFor } from "./server/provider";
|
||||||
export { GlobalItemServerDataRunner } from "./server/runner";
|
export { GlobalItemServerDataRunner } from "./server/runner";
|
||||||
|
export type { WidgetComponentProps };
|
||||||
|
|
||||||
export const widgetImports = {
|
export const widgetImports = {
|
||||||
clock,
|
clock,
|
||||||
@@ -34,6 +37,7 @@ export const widgetImports = {
|
|||||||
iframe,
|
iframe,
|
||||||
video,
|
video,
|
||||||
dnsHoleSummary,
|
dnsHoleSummary,
|
||||||
|
dnsHoleControls,
|
||||||
"smartHome-entityState": smartHomeEntityState,
|
"smartHome-entityState": smartHomeEntityState,
|
||||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||||
mediaServer,
|
mediaServer,
|
||||||
@@ -43,8 +47,6 @@ export const widgetImports = {
|
|||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
export type WidgetImportKey = keyof WidgetImports;
|
export type WidgetImportKey = keyof WidgetImports;
|
||||||
export type { WidgetComponentProps };
|
|
||||||
export type { WidgetDefinition } from "./definition";
|
|
||||||
|
|
||||||
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();
|
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user