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:
Yossi Hillali
2024-08-04 18:47:00 +03:00
committed by GitHub
parent 9013d5dbf0
commit 65c6854e44
14 changed files with 501 additions and 29 deletions

View File

@@ -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;

View File

@@ -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,
},
};
};

View File

@@ -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"));

View File

@@ -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,
};
}
}

View File

@@ -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 (
<Box h="100%" {...boxPropsByLayout(options.layout)}>
{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>
);

View File

@@ -17,7 +17,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
defaultValue: "grid",
}),
})),
supportedIntegrations: ["piHole"],
supportedIntegrations: ["piHole", "adGuardHome"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,

View File

@@ -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 {

View File

@@ -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<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();