Skip to content

Commit

Permalink
feat: add pi hole summary integration (#521)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Meierschlumpf authored May 26, 2024
1 parent 96c71ae commit d57b771
Show file tree
Hide file tree
Showing 45 changed files with 902 additions and 124 deletions.
2 changes: 2 additions & 0 deletions apps/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.0.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
Expand Down Expand Up @@ -55,6 +56,7 @@
"postcss-preset-mantine": "^1.15.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-error-boundary": "^4.0.13",
"sass": "^1.77.2",
"superjson": "2.2.1",
"use-deep-compare-effect": "^1.8.1"
Expand Down
40 changes: 26 additions & 14 deletions apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@
import { useCallback, useMemo, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";

import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions, BoardItemIntegration } from "@homarr/validation";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
WidgetEditModal,
widgetImports,
} from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";

import { PreviewDimensionsModal } from "./_dimension-modal";
import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal";

interface WidgetPreviewPageContentProps {
kind: WidgetKind;
Expand All @@ -41,11 +44,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
});
const [state, setState] = useState<{
options: Record<string, unknown>;
integrations: BoardItemIntegration[];
integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions;
}>({
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
integrations: [],
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
Expand Down Expand Up @@ -86,17 +89,26 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
return (
<>
<Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<Comp
options={state.options as never}
integrations={state.integrations.map(
(stateIntegration) => integrationData.find((integration) => integration.id === stateIntegration.id)!,
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError kind={kind} error={error as unknown} resetErrorBoundary={resetErrorBoundary} />
)}
>
<Comp
options={state.options as never}
integrationIds={state.integrationIds}
width={dimensions.width}
height={dimensions.height}
isEditMode={editMode}
boardId={undefined}
itemId={undefined}
/>
</ErrorBoundary>
)}
width={dimensions.width}
height={dimensions.height}
isEditMode={editMode}
boardId={undefined}
itemId={undefined}
/>
</QueryErrorResetBoundary>
</Card>
<Affix bottom={12} right={72}>
<ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}>
Expand Down
8 changes: 4 additions & 4 deletions apps/nextjs/src/components/board/items/item-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback } from "react";

import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
import type { BoardItemAdvancedOptions, BoardItemIntegration } from "@homarr/validation";
import type { BoardItemAdvancedOptions } from "@homarr/validation";

import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
Expand Down Expand Up @@ -38,7 +38,7 @@ interface UpdateItemAdvancedOptions {

interface UpdateItemIntegrations {
itemId: string;
newIntegrations: BoardItemIntegration[];
newIntegrations: string[];
}

interface CreateItem {
Expand All @@ -63,7 +63,7 @@ export const useItemActions = () => {
options: {},
width: 1,
height: 1,
integrations: [],
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
Expand Down Expand Up @@ -157,7 +157,7 @@ export const useItemActions = () => {
if (item.id !== itemId) return item;
return {
...item,
...("integrations" in item ? { integrations: newIntegrations } : {}),
...("integrationIds" in item ? { integrationIds: newIntegrations } : {}),
};
}),
};
Expand Down
67 changes: 50 additions & 17 deletions apps/nextjs/src/components/board/sections/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
// Ignored because of gridstack attributes

import type { RefObject } from "react";
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { ActionIcon, Card, Menu } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import combineClasses from "clsx";
import { ErrorBoundary } from "react-error-boundary";

import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
Expand All @@ -18,6 +20,7 @@ import {
WidgetEditModal,
widgetImports,
} from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";

import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
Expand Down Expand Up @@ -104,22 +107,43 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => {
if (!serverData?.isReady) return null;

return (
<>
<ItemMenu offset={4} item={newItem} />
<Comp
options={options as never}
integrations={item.integrations}
serverData={serverData?.data as never}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
{...dimensions}
/>
</>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<ItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
<WidgetError kind={item.kind} error={error as unknown} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
<ItemMenu offset={4} item={newItem} />
<Comp
options={options as never}
integrationIds={item.integrationIds}
serverData={serverData?.data as never}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
{...dimensions}
/>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};

const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
const ItemMenu = ({
offset,
item,
resetErrorBoundary,
}: {
offset: number;
item: Item;
resetErrorBoundary?: () => void;
}) => {
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
const { openModal } = useModalAction(WidgetEditModal);
Expand All @@ -129,6 +153,14 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);

// Reset error boundary on next render if item has been edited
useEffect(() => {
if (refResetErrorBoundaryOnNextRender.current) {
resetErrorBoundary?.();
refResetErrorBoundaryOnNextRender.current = false;
}
}, [item, resetErrorBoundary]);

if (!isEditMode || isPending) return null;

const openEditModal = () => {
Expand All @@ -137,9 +169,9 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
value: {
advancedOptions: item.advancedOptions,
options: item.options,
integrations: item.integrations,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: ({ options, integrations, advancedOptions }) => {
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
updateItemOptions({
itemId: item.id,
newOptions: options,
Expand All @@ -150,8 +182,9 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
});
updateItemIntegrations({
itemId: item.id,
newIntegrations: integrations,
newIntegrations: integrationIds,
});
refResetErrorBoundaryOnNextRender.current = true;
},
integrationData: (integrationData ?? []).filter(
(integration) =>
Expand Down
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/tasks": "workspace:^0.1.0",
Expand Down
76 changes: 76 additions & 0 deletions packages/api/src/middlewares/integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { TRPCError } from "@trpc/server";

import { and, eq, inArray } from "@homarr/db";
import { integrations } from "@homarr/db/schema/sqlite";
import type { IntegrationKind } from "@homarr/definitions";
import { z } from "@homarr/validation";

import { decryptSecret } from "../router/integration";
import { publicProcedure } from "../trpc";

export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
with: {
secrets: true,
},
});

if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
});
}

const { secrets, kind, ...rest } = integration;

return next({
ctx: {
integration: {
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
},
},
});
});
};

export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1) }))
.use(async ({ ctx, input, next }) => {
const dbIntegrations = await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
},
});

const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
});
}

return next({
ctx: {
integrations: dbIntegrations.map(({ secrets, kind, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
})),
},
});
});
};
17 changes: 9 additions & 8 deletions packages/api/src/router/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,15 @@ export const boardRouter = createTRPCRouter({
);
}

const inputIntegrationRelations = inputItems.flatMap(({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
Expand Down Expand Up @@ -277,6 +277,7 @@ export const boardRouter = createTRPCRouter({
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
Expand Down Expand Up @@ -514,9 +515,9 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
sections: sections.map((section) =>
parseSection({
...section,
items: section.items.map((item) => ({
items: section.items.map(({ integrations: itemIntegrations, ...item }) => ({
...item,
integrations: item.integrations.map((item) => item.integration),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
})),
Expand Down
4 changes: 1 addition & 3 deletions packages/api/src/router/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,6 @@ export const integrationRouter = createTRPCRouter({
const algorithm = "aes-256-cbc"; //Using AES encryption
const key = Buffer.from("1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d", "hex"); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')

//Encrypting text
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), initializationVector);
Expand All @@ -219,8 +218,7 @@ export function encryptSecret(text: string): `${string}.${string}` {
return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
}

// Decrypting text
function decryptSecret(value: `${string}.${string}`) {
export function decryptSecret(value: `${string}.${string}`) {
const [data, dataIv] = value.split(".") as [string, string];
const initializationVector = Buffer.from(dataIv, "hex");
const encryptedText = Buffer.from(data, "hex");
Expand Down
Loading

0 comments on commit d57b771

Please sign in to comment.