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:
@@ -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}`)}
|
||||
|
||||
@@ -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}`)}
|
||||
/>
|
||||
|
||||
@@ -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"];
|
||||
|
||||
146
packages/widgets/src/dns-hole/summary/component.tsx
Normal file
146
packages/widgets/src/dns-hole/summary/component.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
29
packages/widgets/src/dns-hole/summary/index.ts
Normal file
29
packages/widgets/src/dns-hole/summary/index.ts
Normal 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"));
|
||||
24
packages/widgets/src/dns-hole/summary/serverData.ts
Normal file
24
packages/widgets/src/dns-hole/summary/serverData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
36
packages/widgets/src/errors/base-component.tsx
Normal file
36
packages/widgets/src/errors/base-component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
packages/widgets/src/errors/base.ts
Normal file
11
packages/widgets/src/errors/base.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
42
packages/widgets/src/errors/component.tsx
Normal file
42
packages/widgets/src/errors/component.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
2
packages/widgets/src/errors/index.ts
Normal file
2
packages/widgets/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./no-integration-selected";
|
||||
export * from "./base";
|
||||
19
packages/widgets/src/errors/no-integration-selected.tsx
Normal file
19
packages/widgets/src/errors/no-integration-selected.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]]) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user