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

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

View File

@@ -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<string, unknown>;
integrations: BoardItemIntegration[];
integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions;
}>({
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
integrations: [],
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
@@ -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}>

View File

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

View File

@@ -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 (
<>
<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);
@@ -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) =>

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DnsHoleSummary>(`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,
};
}),
});

View File

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

View File

@@ -3,3 +3,4 @@ export * from "./string";
export * from "./cookie";
export * from "./array";
export * from "./stopwatch";
export * from "./number";

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -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"
}

View File

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

View File

@@ -0,0 +1,6 @@
import type { IntegrationSecretKind } from "@homarr/definitions";
export interface IntegrationSecret {
kind: IntegrationSecretKind;
value: string;
}

View File

@@ -0,0 +1 @@
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";

View File

@@ -0,0 +1,5 @@
import type { DnsHoleSummary } from "./dns-hole-summary-types";
export interface DnsHoleSummaryIntegration {
getSummaryAsync(): Promise<DnsHoleSummary>;
}

View File

@@ -0,0 +1,6 @@
export interface DnsHoleSummary {
domainsBeingBlocked: number;
adsBlockedToday: number;
adsBlockedTodayPercentage: number;
dnsQueriesToday: number;
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

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

View File

@@ -58,23 +58,70 @@ const cacheClient = createRedisConnection();
* @param name name of the channel
* @returns cache channel object
*/
export const createCacheChannel = <TData>(name: string) => {
export const createCacheChannel = <TData>(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<TData>(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<TData>) => {
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() }));
},
};
};

View File

@@ -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",

View File

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

View File

@@ -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",

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 (

112
pnpm-lock.yaml generated
View File

@@ -81,6 +81,9 @@ importers:
'@homarr/gridstack':
specifier: ^1.0.0
version: 1.0.2
'@homarr/integrations':
specifier: workspace:^0.1.0
version: link:../../packages/integrations
'@homarr/log':
specifier: workspace:^
version: link:../../packages/log
@@ -134,16 +137,16 @@ importers:
version: 5.37.1(@tanstack/react-query@5.38.0(react@18.3.1))(next@14.2.3(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.2))(react@18.3.1)
'@trpc/client':
specifier: 11.0.0-rc.374
version: 11.0.0-rc.374(@trpc/server@11.0.0-rc.374)
version: 11.0.0-rc.374(@trpc/server@11.0.0-rc.377)
'@trpc/next':
specifier: next
version: 11.0.0-rc.374(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.374))(@trpc/react-query@11.0.0-rc.374(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.374))(@trpc/server@11.0.0-rc.374)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@11.0.0-rc.374)(next@14.2.3(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 11.0.0-rc.377(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.377))(@trpc/react-query@11.0.0-rc.377(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.377))(@trpc/server@11.0.0-rc.377)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@11.0.0-rc.377)(next@14.2.3(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@trpc/react-query':
specifier: next
version: 11.0.0-rc.374(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.374))(@trpc/server@11.0.0-rc.374)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 11.0.0-rc.377(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.377))(@trpc/server@11.0.0-rc.377)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@trpc/server':
specifier: next
version: 11.0.0-rc.374
version: 11.0.0-rc.377
'@xterm/addon-canvas':
specifier: ^0.7.0
version: 0.7.0(@xterm/xterm@5.5.0)
@@ -183,6 +186,9 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
react-error-boundary:
specifier: ^4.0.13
version: 4.0.13(react@18.3.1)
sass:
specifier: ^1.77.2
version: 1.77.2
@@ -366,6 +372,9 @@ importers:
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/integrations':
specifier: workspace:^0.1.0
version: link:../integrations
'@homarr/log':
specifier: workspace:^
version: link:../log
@@ -383,10 +392,10 @@ importers:
version: link:../validation
'@trpc/client':
specifier: next
version: 11.0.0-rc.374(@trpc/server@11.0.0-rc.374)
version: 11.0.0-rc.377(@trpc/server@11.0.0-rc.377)
'@trpc/server':
specifier: next
version: 11.0.0-rc.374
version: 11.0.0-rc.377
superjson:
specifier: 2.2.1
version: 2.2.1
@@ -616,6 +625,31 @@ importers:
specifier: ^5.4.5
version: 5.4.5
packages/integrations:
dependencies:
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
version: link:../../tooling/eslint
'@homarr/prettier-config':
specifier: workspace:^0.1.0
version: link:../../tooling/prettier
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
eslint:
specifier: ^8.57.0
version: 8.57.0
typescript:
specifier: ^5.4.5
version: 5.4.5
packages/log:
dependencies:
ioredis:
@@ -863,6 +897,9 @@ importers:
'@homarr/form':
specifier: workspace:^0.1.0
version: link:../form
'@homarr/integrations':
specifier: workspace:^0.1.0
version: link:../integrations
'@homarr/modals':
specifier: workspace:^0.1.0
version: link:../modals
@@ -2320,13 +2357,18 @@ packages:
peerDependencies:
'@trpc/server': 11.0.0-rc.374+5027209bc
'@trpc/next@11.0.0-rc.374':
resolution: {integrity: sha512-yKyS0AcI3RF4ZyGHnSNWqIKC2jDpeP5if+z6qjnjsvMsrRXAt/yFobI9qqsu3eeQqfe0LdwiOPAax6A0ALeKXw==}
'@trpc/client@11.0.0-rc.377':
resolution: {integrity: sha512-76l9naqQXtVDnkyDs12DrXhzD3fw1SMe7Fzcnc9mxYQlJpbQebGdXnN0sLn7IFodXp2hhfsTj5Azk6tIKBrqOw==}
peerDependencies:
'@trpc/server': 11.0.0-rc.377+4a672ddd6
'@trpc/next@11.0.0-rc.377':
resolution: {integrity: sha512-jd99QwnUi9/KKUO9MlS7yv00D9dNzuDE8mTjwMWMqZxAMkLkYPlL/2rDHLP9UrAwV/XeiUS3uP0FSXB69u+vNg==}
peerDependencies:
'@tanstack/react-query': ^5.25.0
'@trpc/client': 11.0.0-rc.374+5027209bc
'@trpc/react-query': 11.0.0-rc.374+5027209bc
'@trpc/server': 11.0.0-rc.374+5027209bc
'@trpc/client': 11.0.0-rc.377+4a672ddd6
'@trpc/react-query': 11.0.0-rc.377+4a672ddd6
'@trpc/server': 11.0.0-rc.377+4a672ddd6
next: '*'
react: '>=16.8.0'
react-dom: '>=16.8.0'
@@ -2336,17 +2378,17 @@ packages:
'@trpc/react-query':
optional: true
'@trpc/react-query@11.0.0-rc.374':
resolution: {integrity: sha512-zv/KjlxfeuOS5f0G2wl4L3AKzI/shYLj1sKxbtfGKkLRPMbCBqLlw4b37wRf5BLX4gi8f87FXm11Wwa8okD6aQ==}
'@trpc/react-query@11.0.0-rc.377':
resolution: {integrity: sha512-ze8a4rSySRwx+3qP2L6Qrx5DsSwRTpDTL3jzHTcnVGEhGjKWc7/i9qGcoWmyxR/aSiPI6rG0LG+Kn73A437udA==}
peerDependencies:
'@tanstack/react-query': ^5.25.0
'@trpc/client': 11.0.0-rc.374+5027209bc
'@trpc/server': 11.0.0-rc.374+5027209bc
'@trpc/client': 11.0.0-rc.377+4a672ddd6
'@trpc/server': 11.0.0-rc.377+4a672ddd6
react: '>=18.2.0'
react-dom: '>=18.2.0'
'@trpc/server@11.0.0-rc.374':
resolution: {integrity: sha512-7r76KJwbSV5mffEYQMGBIAVrrJdPot0JzYQD9ZvcInO8q9sn+uVVZQ8XCAq93zXDSodvzGBJhRu3Kn1qXPw5Xg==}
'@trpc/server@11.0.0-rc.377':
resolution: {integrity: sha512-Fzgvf6N03mZRA9F2uR8S20a36ferWD28uJ4OSP1Es+iEGGnvxmoLm31ECIDJcp7qffR/zEA1CLiKgylLaR4z8w==}
'@tsconfig/node10@1.0.9':
resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
@@ -3663,10 +3705,12 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
deprecated: Glob versions prior to v9 are no longer supported
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -4814,6 +4858,11 @@ packages:
peerDependencies:
react: ^18.3.1
react-error-boundary@4.0.13:
resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==}
peerDependencies:
react: '>=16.13.1'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -6918,30 +6967,34 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.374)':
'@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.377)':
dependencies:
'@trpc/server': 11.0.0-rc.374
'@trpc/server': 11.0.0-rc.377
'@trpc/next@11.0.0-rc.374(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.374))(@trpc/react-query@11.0.0-rc.374(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.374))(@trpc/server@11.0.0-rc.374)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@11.0.0-rc.374)(next@14.2.3(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@trpc/client@11.0.0-rc.377(@trpc/server@11.0.0-rc.377)':
dependencies:
'@trpc/client': 11.0.0-rc.374(@trpc/server@11.0.0-rc.374)
'@trpc/server': 11.0.0-rc.374
'@trpc/server': 11.0.0-rc.377
'@trpc/next@11.0.0-rc.377(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.377))(@trpc/react-query@11.0.0-rc.377(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.377))(@trpc/server@11.0.0-rc.377)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@11.0.0-rc.377)(next@14.2.3(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@trpc/client': 11.0.0-rc.374(@trpc/server@11.0.0-rc.377)
'@trpc/server': 11.0.0-rc.377
next: 14.2.3(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.2)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@tanstack/react-query': 5.38.0(react@18.3.1)
'@trpc/react-query': 11.0.0-rc.374(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.374))(@trpc/server@11.0.0-rc.374)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@trpc/react-query': 11.0.0-rc.377(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.377))(@trpc/server@11.0.0-rc.377)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@trpc/react-query@11.0.0-rc.374(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.374))(@trpc/server@11.0.0-rc.374)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@trpc/react-query@11.0.0-rc.377(@tanstack/react-query@5.38.0(react@18.3.1))(@trpc/client@11.0.0-rc.374(@trpc/server@11.0.0-rc.377))(@trpc/server@11.0.0-rc.377)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/react-query': 5.38.0(react@18.3.1)
'@trpc/client': 11.0.0-rc.374(@trpc/server@11.0.0-rc.374)
'@trpc/server': 11.0.0-rc.374
'@trpc/client': 11.0.0-rc.374(@trpc/server@11.0.0-rc.377)
'@trpc/server': 11.0.0-rc.377
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@trpc/server@11.0.0-rc.374': {}
'@trpc/server@11.0.0-rc.377': {}
'@tsconfig/node10@1.0.9': {}
@@ -9873,6 +9926,11 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-error-boundary@4.0.13(react@18.3.1):
dependencies:
'@babel/runtime': 7.23.9
react: 18.3.1
react-is@16.13.1: {}
react-is@18.2.0: {}