feat: add pi hole summary integration (#521)

* feat: add pi hole summary integration

* feat: add pi hole summary widget

* fix: type issues with integrations and integrationIds

* feat: add middleware for integrations and improve cache redis channel

* feat: add error boundary for widgets

* fix: broken lock file

* fix: format format issues

* fix: typecheck issue

* fix: deepsource issues

* fix: widget sandbox without error boundary

* chore: address pull request feedback

* chore: remove todo comment and created issue

* fix: format issues

* fix: deepsource issue
This commit is contained in:
Meier Lukas
2024-05-26 17:13:34 +02:00
committed by GitHub
parent 96c71aed6e
commit d57b771a17
45 changed files with 902 additions and 124 deletions

View File

@@ -2,10 +2,11 @@
import { MultiSelect } from "@mantine/core";
import { translateIfNecessary } from "@homarr/translation";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
import type { SelectOption } from "./widget-select-input";
export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"multiSelect">) => {
const t = useWidgetInputTranslation(kind, property);
@@ -14,7 +15,14 @@ export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidget
return (
<MultiSelect
label={t("label")}
data={options.options as unknown as SelectOption[]}
data={options.options.map((option) =>
typeof option === "string"
? option
: {
value: option.value,
label: translateIfNecessary(t, option.label)!,
},
)}
description={options.withDescription ? t("description") : undefined}
searchable={options.searchable}
{...form.getInputProps(`options.${property}`)}

View File

@@ -2,6 +2,10 @@
import { Select } from "@mantine/core";
import { translateIfNecessary } from "@homarr/translation";
import type { stringOrTranslation } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
@@ -9,7 +13,7 @@ import { useFormContext } from "./form";
export type SelectOption =
| {
value: string;
label: string;
label: stringOrTranslation;
}
| string;
@@ -20,14 +24,22 @@ export type inferSelectOptionValue<TOption extends SelectOption> = TOption exten
: TOption;
export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"select">) => {
const t = useWidgetInputTranslation(kind, property);
const t = useI18n();
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
<Select
label={t("label")}
data={options.options as unknown as SelectOption[]}
description={options.withDescription ? t("description") : undefined}
label={tWidget("label")}
data={options.options.map((option) =>
typeof option === "string"
? option
: {
value: option.value,
label: translateIfNecessary(t, option.label)!,
},
)}
description={options.withDescription ? tWidget("description") : undefined}
searchable={options.searchable}
{...form.getInputProps(`options.${property}`)}
/>

View File

@@ -1,11 +1,12 @@
import type { LoaderComponent } from "next/dynamic";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
import type { IntegrationSelectOption } from "./widget-integration-select";
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
default: (props: WidgetProps<TKind>) => Promise<Record<string, unknown>>;
@@ -64,11 +65,20 @@ export interface WidgetDefinition {
icon: TablerIcon;
supportedIntegrations?: IntegrationKind[];
options: WidgetOptionsRecord;
errors?: Partial<
Record<
DefaultErrorData["code"],
{
icon: TablerIcon;
message: stringOrTranslation;
}
>
>;
}
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
integrations: inferIntegrationsFromDefinition<WidgetImports[TKind]["definition"]>;
integrationIds: string[];
}
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
@@ -87,19 +97,4 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
height: number;
};
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> = TDefinition extends {
supportedIntegrations: infer TSupportedIntegrations;
} // check if definition has supportedIntegrations
? TSupportedIntegrations extends IntegrationKind[] // check if supportedIntegrations is an array of IntegrationKind
? IntegrationSelectOptionFor<TSupportedIntegrations[number]>[] // if so, return an array of IntegrationSelectOptionFor
: IntegrationSelectOption[] // otherwise, return an array of IntegrationSelectOption without specifying the kind
: IntegrationSelectOption[];
interface IntegrationSelectOptionFor<TIntegration extends IntegrationKind> {
id: string;
name: string;
url: string;
kind: TIntegration[number];
}
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["options"];

View File

@@ -0,0 +1,146 @@
"use client";
import type { BoxProps } from "@mantine/core";
import { Box, Card, Center, Flex, Text } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
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 type { WidgetComponentProps, WidgetProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<"dnsHoleSummary">) {
const integrationId = integrationIds.at(0);
if (!integrationId) {
throw new NoIntegrationSelectedError();
}
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
integrationId,
},
{
refetchOnMount: false,
retry: false,
},
);
return (
<Box h="100%" {...boxPropsByLayout(options.layout)}>
{stats.map((item, index) => (
<StatCard key={index} item={item} usePiHoleColors={options.usePiHoleColors} data={data} />
))}
</Box>
);
}
const stats = [
{
icon: IconBarrierBlock,
value: ({ adsBlockedToday }) => formatNumber(adsBlockedToday, 2),
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
color: "rgba(240, 82, 60, 0.4)", // RED
},
{
icon: IconPercentage,
value: ({ adsBlockedTodayPercentage }, t) =>
t("common.rtl", {
value: formatNumber(adsBlockedTodayPercentage, 2),
symbol: "%",
}),
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
color: "rgba(255, 165, 20, 0.4)", // YELLOW
},
{
icon: IconSearch,
value: ({ dnsQueriesToday }) => formatNumber(dnsQueriesToday, 2),
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
color: "rgba(0, 175, 218, 0.4)", // BLUE
},
{
icon: IconWorldWww,
value: ({ domainsBeingBlocked }) => formatNumber(domainsBeingBlocked, 2),
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
color: "rgba(0, 176, 96, 0.4)", // GREEN
},
] satisfies StatItem[];
interface StatItem {
icon: TablerIcon;
value: (x: RouterOutputs["widget"]["dnsHole"]["summary"], t: TranslationFunction) => string;
label: stringOrTranslation;
color: string;
}
interface StatCardProps {
item: StatItem;
data: RouterOutputs["widget"]["dnsHole"]["summary"];
usePiHoleColors: boolean;
}
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
const { ref, height, width } = useElementSize();
const isLong = width > height + 20;
const t = useI18n();
return (
<Card
ref={ref}
m={6}
p={3}
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
style={{
flex: 1,
}}
withBorder
>
<Center h="100%" w="100%">
<Flex h="100%" w="100%" align="center" justify="space-evenly" direction={isLong ? "row" : "column"}>
<item.icon size={30} style={{ margin: "0 10" }} />
<Flex
justify="center"
direction="column"
style={{
flex: isLong ? 1 : undefined,
}}
>
<Text ta="center" lh={1.2} size="md" fw="bold">
{item.value(data, t)}
</Text>
{item.label && (
<Text ta="center" lh={1.2} size="0.75rem">
{translateIfNecessary(t, item.label)}
</Text>
)}
</Flex>
</Flex>
</Center>
</Card>
);
};
const boxPropsByLayout = (layout: WidgetProps<"dnsHoleSummary">["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 { IconAd, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("dnsHoleSummary", {
icon: IconAd,
options: optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
defaultValue: true,
}),
layout: factory.select({
options: (["grid", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
})),
defaultValue: "grid",
}),
})),
supportedIntegrations: ["piHole"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,24 @@
"use server";
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 };
try {
const data = await api.widget.dnsHole.summary({
integrationId,
});
return {
initialData: data,
};
} catch (error) {
return {
initialData: undefined,
};
}
}

View File

@@ -0,0 +1,36 @@
import Link from "next/link";
import { Anchor, Button, Stack, Text } from "@mantine/core";
import type { stringOrTranslation } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
interface BaseWidgetErrorProps {
icon: TablerIcon;
message: stringOrTranslation;
showLogsLink?: boolean;
onRetry: () => void;
}
export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n();
return (
<Stack h="100%" align="center" justify="center" gap="md">
<props.icon size={40} />
<Stack gap={0}>
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
{props.showLogsLink && (
<Anchor component={Link} href="/manage/tools/logs" target="_blank" ta="center" size="sm">
{t("widget.common.error.action.logs")}
</Anchor>
)}
</Stack>
<Button onClick={props.onRetry} size="sm" variant="light">
{t("common.action.tryAgain")}
</Button>
</Stack>
);
};

View File

@@ -0,0 +1,11 @@
import type { TablerIcon } from "@tabler/icons-react";
import type { stringOrTranslation } from "@homarr/translation";
export abstract class ErrorBoundaryError extends Error {
public abstract getErrorBoundaryData(): {
icon: TablerIcon;
message: stringOrTranslation;
showLogsLink: boolean;
};
}

View File

@@ -0,0 +1,42 @@
import { useMemo } from "react";
import { IconExclamationCircle } from "@tabler/icons-react";
import { TRPCClientError } from "@trpc/client";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { WidgetKind } from "@homarr/definitions";
import { widgetImports } from "..";
import { ErrorBoundaryError } from "./base";
import { BaseWidgetError } from "./base-component";
interface WidgetErrorProps {
kind: WidgetKind;
error: unknown;
resetErrorBoundary: () => void;
}
export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProps) => {
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
if (error instanceof ErrorBoundaryError) {
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
}
if (error instanceof TRPCClientError && "code" in error.data) {
const errorData = error.data as DefaultErrorData;
if (!("errors" in currentDefinition && errorData.code in currentDefinition.errors)) return null;
const errorDefinition = currentDefinition.errors[errorData.code as keyof typeof currentDefinition.errors];
return <BaseWidgetError {...errorDefinition} onRetry={resetErrorBoundary} showLogsLink />;
}
return (
<BaseWidgetError
icon={IconExclamationCircle}
message={(error as { toString: () => string }).toString()}
onRetry={resetErrorBoundary}
/>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./no-integration-selected";
export * from "./base";

View File

@@ -0,0 +1,19 @@
import { IconPlugX } from "@tabler/icons-react";
import type { TranslationFunction } from "@homarr/translation";
import { ErrorBoundaryError } from "./base";
export class NoIntegrationSelectedError extends ErrorBoundaryError {
constructor() {
super("No integration selected");
}
public getErrorBoundaryData() {
return {
icon: IconPlugX,
message: (t: TranslationFunction) => t("widget.common.error.noIntegration"),
showLogsLink: false,
};
}
}

View File

@@ -8,6 +8,7 @@ import type { WidgetKind } from "@homarr/definitions";
import * as app from "./app";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import * as dnsHoleSummary from "./dns-hole/summary";
import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
import * as notebook from "./notebook";
@@ -27,6 +28,7 @@ export const widgetImports = {
notebook,
iframe,
video,
dnsHoleSummary,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -6,7 +6,6 @@ import { Button, Group, Stack } from "@mantine/core";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { BoardItemIntegration } from "@homarr/validation";
import { widgetImports } from "..";
import { getInputForType } from "../_inputs";
@@ -19,8 +18,8 @@ import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
export interface WidgetEditModalState {
options: Record<string, unknown>;
integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions;
integrations: BoardItemIntegration[];
}
interface ModalProps<TSort extends WidgetKind> {
@@ -57,7 +56,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrations")}
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => {

View File

@@ -55,13 +55,14 @@ export const WidgetIntegrationSelect = ({
const handleValueRemove = (valueToRemove: string) =>
onChange(multiSelectValues.filter((value) => value !== valueToRemove));
const values = multiSelectValues.map((item) => (
<IntegrationPill
key={item}
option={data.find((integration) => integration.id === item)!}
onRemove={() => handleValueRemove(item)}
/>
));
const values = multiSelectValues.map((item) => {
const option = data.find((integration) => integration.id === item);
if (!option) {
return null;
}
return <IntegrationPill key={item} option={option} onRemove={() => handleValueRemove(item)} />;
});
const options = data.map((item) => {
return (