diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 5c78e7643..529e259ca 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -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", @@ -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" diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 9a2a1f100..d38c868a2 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -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; @@ -41,11 +44,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }); const [state, setState] = useState<{ options: Record; - integrations: BoardItemIntegration[]; + integrationIds: string[]; advancedOptions: BoardItemAdvancedOptions; }>({ options: reduceWidgetOptionsWithDefaultValues(kind, {}), - integrations: [], + integrationIds: [], advancedOptions: { customCssClasses: [], }, @@ -86,17 +89,26 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie return ( <> = 96 ? undefined : 4}> - integrationData.find((integration) => integration.id === stateIntegration.id)!, + + {({ reset }) => ( + ( + + )} + > + + )} - width={dimensions.width} - height={dimensions.height} - isEditMode={editMode} - boardId={undefined} - itemId={undefined} - /> + diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index 309dab8bd..2246853ea 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -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"; @@ -38,7 +38,7 @@ interface UpdateItemAdvancedOptions { interface UpdateItemIntegrations { itemId: string; - newIntegrations: BoardItemIntegration[]; + newIntegrations: string[]; } interface CreateItem { @@ -63,7 +63,7 @@ export const useItemActions = () => { options: {}, width: 1, height: 1, - integrations: [], + integrationIds: [], advancedOptions: { customCssClasses: [], }, @@ -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 } : {}), }; }), }; diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index cdd71266f..487207eb4 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -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"; @@ -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"; @@ -104,22 +107,43 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => { if (!serverData?.isReady) return null; return ( - <> - - - + + {({ reset }) => ( + ( + <> + + + + )} + > + + + + )} + ); }; -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); @@ -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 = () => { @@ -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, @@ -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) => diff --git a/packages/api/package.json b/packages/api/package.json index 52fd9fb21..3fad704f3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts new file mode 100644 index 000000000..8f23903fc --- /dev/null +++ b/packages/api/src/middlewares/integration.ts @@ -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 = (...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 = (...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), + })), + })), + }, + }); + }); +}; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index c37e92c57..0776fcb41 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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, })), ); @@ -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)); @@ -514,9 +515,9 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, 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(item.advancedOptions), options: superjson.parse>(item.options), })), diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts index ae807ef97..92fb2fcb9 100644 --- a/packages/api/src/router/integration.ts +++ b/packages/api/src/router/integration.ts @@ -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); @@ -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"); diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 11ac8c913..e5afca76e 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -659,7 +659,7 @@ describe("saveBoard should save full board", () => { id: createId(), kind: "clock", options: { is24HourFormat: true }, - integrations: [], + integrationIds: [], height: 1, width: 1, xOffset: 0, @@ -720,7 +720,7 @@ describe("saveBoard should save full board", () => { id: itemId, kind: "clock", options: { is24HourFormat: true }, - integrations: [anotherIntegration], + integrationIds: [anotherIntegration.id], height: 1, width: 1, xOffset: 0, @@ -834,7 +834,7 @@ describe("saveBoard should save full board", () => { id: newItemId, kind: "clock", options: { is24HourFormat: true }, - integrations: [], + integrationIds: [], height: 1, width: 1, xOffset: 3, @@ -903,7 +903,7 @@ describe("saveBoard should save full board", () => { id: itemId, kind: "clock", options: { is24HourFormat: true }, - integrations: [integration], + integrationIds: [integration.id], height: 1, width: 1, xOffset: 0, @@ -1017,7 +1017,7 @@ describe("saveBoard should save full board", () => { id: itemId, kind: "clock", options: { is24HourFormat: false }, - integrations: [], + integrationIds: [], height: 3, width: 2, xOffset: 7, @@ -1245,10 +1245,9 @@ const expectInputToBeFullBoardWithName = ( if (firstItem.kind === "clock") { expect(firstItem.options.is24HourFormat).toBe(true); } - expect(firstItem.integrations.length).toBe(1); - const firstIntegration = expectToBeDefined(firstItem.integrations[0]); - expect(firstIntegration.id).toBe(props.integrationId); - expect(firstIntegration.kind).toBe("adGuardHome"); + expect(firstItem.integrationIds.length).toBe(1); + const firstIntegration = expectToBeDefined(firstItem.integrationIds[0]); + expect(firstIntegration).toBe(props.integrationId); }; const createFullBoardAsync = async (db: Database, name: string) => { diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts new file mode 100644 index 000000000..a163713c3 --- /dev/null +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -0,0 +1,32 @@ +import { TRPCError } from "@trpc/server"; + +import { PiHoleIntegration } from "@homarr/integrations"; +import type { DnsHoleSummary } from "@homarr/integrations/types"; +import { logger } from "@homarr/log"; +import { createCacheChannel } from "@homarr/redis"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const dnsHoleRouter = createTRPCRouter({ + summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("piHole")).query(async ({ ctx }) => { + const cache = createCacheChannel(`dns-hole-summary:${ctx.integration.id}`); + + const { data } = await cache.consumeAsync(async () => { + const client = new PiHoleIntegration(ctx.integration); + + return await client.getSummaryAsync().catch((err) => { + logger.error("dns-hole router - ", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to fetch DNS Hole summary for ${ctx.integration.name} (${ctx.integration.id})`, + }); + }); + }); + + return { + ...data, + integrationId: ctx.integration.id, + }; + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index ecd39a0a8..f2bd45078 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -1,8 +1,10 @@ import { createTRPCRouter } from "../../trpc"; +import { dnsHoleRouter } from "./dns-hole"; import { notebookRouter } from "./notebook"; import { weatherRouter } from "./weather"; export const widgetRouter = createTRPCRouter({ notebook: notebookRouter, weather: weatherRouter, + dnsHole: dnsHoleRouter, }); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 1089f3537..5ee205da0 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -3,3 +3,4 @@ export * from "./string"; export * from "./cookie"; export * from "./array"; export * from "./stopwatch"; +export * from "./number"; diff --git a/packages/common/src/number.ts b/packages/common/src/number.ts new file mode 100644 index 000000000..5341e5004 --- /dev/null +++ b/packages/common/src/number.ts @@ -0,0 +1,17 @@ +const ranges = [ + { divider: 1e18, suffix: "E" }, + { divider: 1e15, suffix: "P" }, + { divider: 1e12, suffix: "T" }, + { divider: 1e9, suffix: "G" }, + { divider: 1e6, suffix: "M" }, + { divider: 1e3, suffix: "k" }, +]; + +export const formatNumber = (value: number, decimalPlaces: number) => { + for (const range of ranges) { + if (value < range.divider) continue; + + return (value / range.divider).toFixed(decimalPlaces) + range.suffix; + } + return value.toFixed(decimalPlaces); +}; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index a095f01f1..6c50ba23b 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -1,2 +1,2 @@ -export const widgetKinds = ["clock", "weather", "app", "iframe", "video", "notebook"] as const; +export const widgetKinds = ["clock", "weather", "app", "iframe", "video", "notebook", "dnsHoleSummary"] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/index.ts b/packages/integrations/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/packages/integrations/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/integrations/package.json b/packages/integrations/package.json new file mode 100644 index 000000000..5df87673e --- /dev/null +++ b/packages/integrations/package.json @@ -0,0 +1,41 @@ +{ + "name": "@homarr/integrations", + "private": true, + "version": "0.1.0", + "exports": { + ".": "./index.ts", + "./types": "./src/types.ts" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "type": "module", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "eslint .", + "format": "prettier --check . --ignore-path ../../.gitignore", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@homarr/eslint-config": "workspace:^0.2.0", + "@homarr/prettier-config": "workspace:^0.1.0", + "@homarr/tsconfig": "workspace:^0.1.0", + "eslint": "^8.57.0", + "typescript": "^5.4.5" + }, + "dependencies": { + "@homarr/definitions": "workspace:^0.1.0", + "@homarr/validation": "workspace:^0.1.0" + }, + "eslintConfig": { + "extends": [ + "@homarr/eslint-config/base" + ] + }, + "prettier": "@homarr/prettier-config" +} diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts new file mode 100644 index 000000000..e8612736a --- /dev/null +++ b/packages/integrations/src/base/integration.ts @@ -0,0 +1,22 @@ +import type { IntegrationSecretKind } from "@homarr/definitions"; + +import type { IntegrationSecret } from "./types"; + +export abstract class Integration { + constructor( + protected integration: { + id: string; + name: string; + url: string; + decryptedSecrets: IntegrationSecret[]; + }, + ) {} + + protected getSecretValue(kind: IntegrationSecretKind) { + const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind); + if (!secret) { + throw new Error(`No secret of kind ${kind} was found`); + } + return secret.value; + } +} diff --git a/packages/integrations/src/base/types.ts b/packages/integrations/src/base/types.ts new file mode 100644 index 000000000..f466508ca --- /dev/null +++ b/packages/integrations/src/base/types.ts @@ -0,0 +1,6 @@ +import type { IntegrationSecretKind } from "@homarr/definitions"; + +export interface IntegrationSecret { + kind: IntegrationSecretKind; + value: string; +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts new file mode 100644 index 000000000..4740f40bd --- /dev/null +++ b/packages/integrations/src/index.ts @@ -0,0 +1 @@ +export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; diff --git a/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-integration.ts b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-integration.ts new file mode 100644 index 000000000..d25be7fd0 --- /dev/null +++ b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-integration.ts @@ -0,0 +1,5 @@ +import type { DnsHoleSummary } from "./dns-hole-summary-types"; + +export interface DnsHoleSummaryIntegration { + getSummaryAsync(): Promise; +} diff --git a/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts new file mode 100644 index 000000000..2295f9079 --- /dev/null +++ b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts @@ -0,0 +1,6 @@ +export interface DnsHoleSummary { + domainsBeingBlocked: number; + adsBlockedToday: number; + adsBlockedTodayPercentage: number; + dnsQueriesToday: number; +} diff --git a/packages/integrations/src/pi-hole/pi-hole-integration.ts b/packages/integrations/src/pi-hole/pi-hole-integration.ts new file mode 100644 index 000000000..4b309c07b --- /dev/null +++ b/packages/integrations/src/pi-hole/pi-hole-integration.ts @@ -0,0 +1,31 @@ +import { Integration } from "../base/integration"; +import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration"; +import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types"; +import { summaryResponseSchema } from "./pi-hole-types"; + +export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration { + async getSummaryAsync(): Promise { + const apiKey = super.getSecretValue("apiKey"); + const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`); + if (!response.ok) { + throw new Error( + `Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, + ); + } + + const result = summaryResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new Error( + `Failed to parse summary for ${this.integration.name} (${this.integration.id}), most likely your api key is wrong: ${result.error.message}`, + ); + } + + return { + adsBlockedToday: result.data.ads_blocked_today, + adsBlockedTodayPercentage: result.data.ads_percentage_today, + domainsBeingBlocked: result.data.domains_being_blocked, + dnsQueriesToday: result.data.dns_queries_today, + }; + } +} diff --git a/packages/integrations/src/pi-hole/pi-hole-types.ts b/packages/integrations/src/pi-hole/pi-hole-types.ts new file mode 100644 index 000000000..6b2a28a95 --- /dev/null +++ b/packages/integrations/src/pi-hole/pi-hole-types.ts @@ -0,0 +1,9 @@ +import { z } from "@homarr/validation"; + +export const summaryResponseSchema = z.object({ + status: z.enum(["enabled", "disabled"]), + domains_being_blocked: z.number(), + ads_blocked_today: z.number(), + dns_queries_today: z.number(), + ads_percentage_today: z.number(), +}); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts new file mode 100644 index 000000000..981a5fcb0 --- /dev/null +++ b/packages/integrations/src/types.ts @@ -0,0 +1 @@ +export * from "./interfaces/dns-hole-summary/dns-hole-summary-types"; diff --git a/packages/integrations/tsconfig.json b/packages/integrations/tsconfig.json new file mode 100644 index 000000000..cbe8483d9 --- /dev/null +++ b/packages/integrations/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 06ec8006b..beb5f1c70 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -1,5 +1,7 @@ import { createQueueChannel, createSubPubChannel } from "./lib/channel"; +export { createCacheChannel } from "./lib/channel"; + export const exampleChannel = createSubPubChannel<{ message: string }>("example"); export const queueChannel = createQueueChannel<{ name: string; diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index eeb4d13ba..270b3c6ce 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -58,23 +58,70 @@ const cacheClient = createRedisConnection(); * @param name name of the channel * @returns cache channel object */ -export const createCacheChannel = (name: string) => { +export const createCacheChannel = (name: string, cacheDurationSeconds: number = 5 * 60 * 1000) => { const cacheChannelName = `cache:${name}`; + return { /** * Get the data from the cache channel. - * @returns data or undefined if not found + * @returns data or null if not found or expired */ getAsync: async () => { const data = await cacheClient.get(cacheChannelName); - return data ? superjson.parse(data) : undefined; + if (!data) return null; + + const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data); + const now = new Date(); + const diff = now.getTime() - parsedData.timestamp.getTime(); + if (diff > cacheDurationSeconds) return null; + + return parsedData; + }, + /** + * Consume the data from the cache channel, if not present or expired, it will call the callback to get new data. + * @param callback callback function to get new data if not present or expired + * @returns data or new data if not present or expired + */ + consumeAsync: async (callback: () => Promise) => { + const data = await cacheClient.get(cacheChannelName); + + const getNewDataAsync = async () => { + logger.debug(`Cache miss for channel '${cacheChannelName}'`); + const newData = await callback(); + const result = { data: newData, timestamp: new Date() }; + await cacheClient.set(cacheChannelName, superjson.stringify(result)); + logger.debug(`Cache updated for channel '${cacheChannelName}'`); + return result; + }; + + if (!data) { + return await getNewDataAsync(); + } + + const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data); + const now = new Date(); + const diff = now.getTime() - parsedData.timestamp.getTime(); + + if (diff > cacheDurationSeconds) { + return await getNewDataAsync(); + } + + logger.debug(`Cache hit for channel '${cacheChannelName}'`); + + return parsedData; + }, + /** + * Invalidate the cache channels data. + */ + invalidateAsync: async () => { + await cacheClient.del(cacheChannelName); }, /** * Set the data in the cache channel. * @param data data to be stored in the cache channel */ setAsync: async (data: TData) => { - await cacheClient.set(cacheChannelName, superjson.stringify(data)); + await cacheClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() })); }, }; }; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 3ba679d51..fa5d56756 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -447,6 +447,7 @@ export default { previous: "Previous", next: "Next", checkoutDocs: "Check out the documentation", + tryAgain: "Try again", }, iconPicker: { header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.", @@ -531,7 +532,6 @@ export default { custom: { passwordsDoNotMatch: "Passwords do not match", boardAlreadyExists: "A board with this name already exists", - // TODO: Add custom error messages }, }, }, @@ -641,6 +641,38 @@ export default { }, }, }, + dnsHoleSummary: { + name: "DNS Hole Summary", + description: "Displays the summary of your DNS Hole", + option: { + layout: { + label: "Layout", + option: { + row: { + label: "Horizontal", + }, + column: { + label: "Vertical", + }, + grid: { + label: "Grid", + }, + }, + }, + usePiHoleColors: { + label: "Use Pi-Hole colors", + }, + }, + error: { + internalServerError: "Failed to fetch DNS Hole Summary", + }, + data: { + adsBlockedToday: "blocked today", + adsBlockedTodayPercentage: "blocked today", + dnsQueriesToday: "Queries today", + domainsBeingBlocked: "Domains on blocklist", + }, + }, clock: { name: "Date and time", description: "Displays the current date and time.", @@ -834,6 +866,12 @@ export default { }, }, }, + error: { + action: { + logs: "Check logs for more details", + }, + noIntegration: "No integration selected", + }, }, video: { name: "Video Stream", diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts index 46b2de2c1..b524f3716 100644 --- a/packages/validation/src/shared.ts +++ b/packages/validation/src/shared.ts @@ -25,7 +25,7 @@ export const sharedItemSchema = z.object({ yOffset: z.number(), height: z.number(), width: z.number(), - integrations: z.array(integrationSchema), + integrationIds: z.array(z.string()), advancedOptions: itemAdvancedOptionsSchema, }); diff --git a/packages/widgets/package.json b/packages/widgets/package.json index a31e97954..42ffa2132 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -3,7 +3,8 @@ "private": true, "version": "0.1.0", "exports": { - ".": "./index.ts" + ".": "./index.ts", + "./errors": "./src/errors/component.tsx" }, "typesVersions": { "*": { @@ -39,6 +40,7 @@ "@homarr/common": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", + "@homarr/integrations": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", diff --git a/packages/widgets/src/_inputs/widget-multiselect-input.tsx b/packages/widgets/src/_inputs/widget-multiselect-input.tsx index 282ae5c2f..9f3db9e92 100644 --- a/packages/widgets/src/_inputs/widget-multiselect-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiselect-input.tsx @@ -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 ( + 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}`)} diff --git a/packages/widgets/src/_inputs/widget-select-input.tsx b/packages/widgets/src/_inputs/widget-select-input.tsx index aa855dbf8..fd1df1d8f 100644 --- a/packages/widgets/src/_inputs/widget-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-select-input.tsx @@ -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 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 (