feat(widgets): consolidate integration metadata + shared response caching (#6691)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-30 18:38:07 +02:00
1 parent 7d1d9062ed
commit 8d8dec5749
206 files changed
+3893 -1946

No files matched your search

+55 -19
View File
@@ -1,4 +1,4 @@
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4";
export interface McpMeta {
@@ -12,6 +12,7 @@ export interface McpMeta {
export interface McpTool {
name: string;
description: string;
type: "query" | "mutation";
pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema;
inputValidator: z.ZodObject;
@@ -19,13 +20,54 @@ export interface McpTool {
const emptyInput = z.object({});
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const getObjectInput = (input: z.ZodType) => {
if (input instanceof z.ZodObject) return input;
if (input instanceof z.ZodOptional) {
const unwrappedInput = input.unwrap();
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
}
return null;
};
const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
if (!meta || typeof meta !== "object" || !("mcp" in meta)) return undefined;
const metadata = meta.mcp;
if (!metadata || typeof metadata !== "object" || !("enabled" in metadata)) return undefined;
if (typeof metadata.enabled !== "boolean") return undefined;
const result: NonNullable<McpMeta["mcp"]> = { enabled: metadata.enabled };
if ("name" in metadata && typeof metadata.name === "string") result.name = metadata.name;
if ("description" in metadata && typeof metadata.description === "string") {
result.description = metadata.description;
}
return result;
};
const getProcedureInput = (inputs: z.ZodType[]) => {
if (inputs.length === 0) return emptyInput;
if (inputs.length === 1) {
const input = inputs[0] ?? emptyInput;
return getObjectInput(input) ?? input;
}
const objectInputs = inputs.map(getObjectInput);
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
return null;
};
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = ((schema.required as string[] | undefined) ?? []).filter(
(key) => !("default" in (properties[key] ?? {})),
);
const properties = schema.properties ?? {};
const required = (schema.required ?? []).filter((key) => {
const property = properties[key];
return !property || typeof property !== "object" || !("default" in property);
});
return {
...schema,
@@ -41,22 +83,15 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
const tools: McpTool[] = [];
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
const definition = procedure["_def"] as unknown as {
inputs?: z.ZodType[];
meta?: McpMeta;
};
const metadata = definition.meta?.mcp;
if (!isProcedure(procedure)) continue;
const definition = procedure["_def"];
if (definition.type === "subscription") continue;
const metadata = getMcpMetadata(definition.meta);
if (!metadata?.enabled) continue;
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
const procedureInput =
inputs.length === 0
? emptyInput
: inputs.length === 1
? (inputs[0] ?? emptyInput)
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
? mergeInputs(inputs)
: null;
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
const procedureInput = getProcedureInput(inputs);
if (
procedureInput === null ||
@@ -69,6 +104,7 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
+180 -71
View File
@@ -9,26 +9,51 @@ import type { AtLeastOneOf } from "@homarr/common/types";
import type { Database } from "@homarr/db";
import { and, eq, inArray } from "@homarr/db";
import { integrations } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { publicProcedure } from "../trpc";
export type IntegrationAction = "query" | "interact";
/**
* Creates a middleware that provides the integration in the context that is of the specified kinds
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
*/
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
const logger = createLogger({ module: "integrationMiddleware" });
const getIntegrationKindForLog = (integration: object) => {
if ("kind" in integration && typeof integration.kind === "string") return integration.kind;
return "unknown";
};
interface IntegrationAccessMetadata {
action: IntegrationAction;
cardinality: "one" | "many";
userId: string | undefined;
requestedIntegrationIds: readonly string[];
allowedIntegrationKinds: readonly IntegrationKind[];
}
const throwUnexpectedIntegrationKind = (kind: IntegrationKind, allowedKinds: readonly IntegrationKind[]): never => {
logger.error("Database returned an integration outside the requested kinds", {
integrationKind: kind,
allowedIntegrationKinds: allowedKinds,
});
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Integration kind validation failed" });
};
const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
kinds: readonly TKind[],
) => {
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "one",
userId: ctx.session?.user.id,
requestedIntegrationIds: [input.integrationId],
allowedIntegrationKinds: kinds,
};
logger.debug("Resolving integration access", accessMetadata);
const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
with: {
@@ -45,6 +70,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
});
if (!integration) {
logger.warn("Integration access target was not found", accessMetadata);
throw new TRPCError({
code: "NOT_FOUND",
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
@@ -52,22 +78,24 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
}
await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: [integration.kind],
});
const {
secrets,
kind,
items: _ignore1,
groupPermissions: _ignore2,
userPermissions: _ignore3,
...rest
} = integration;
const narrowedKind = kinds.find((kind) => kind === integration.kind);
if (!narrowedKind) {
return throwUnexpectedIntegrationKind(integration.kind, kinds);
}
const { secrets, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest } = integration;
return next({
ctx: {
integration: {
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
kind: narrowedKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
@@ -78,6 +106,101 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
});
};
/**
* Creates a middleware that provides the integration in the context that is of the specified kinds
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
*/
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return createOneIntegrationMiddlewareForKinds(action, kinds);
};
const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
action: IntegrationAction,
kinds: readonly TKind[],
) => {
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "many",
userId: ctx.session?.user.id,
requestedIntegrationIds: input.integrationIds,
allowedIntegrationKinds: kinds,
};
logger.debug("Resolving integration access", accessMetadata);
const findIntegrations = async () => {
if (input.integrationIds.length === 0) return [];
return await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: true,
groupPermissions: true,
},
});
};
const dbIntegrations = await findIntegrations();
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
logger.warn("Integration access targets were not found", {
...accessMetadata,
missingIntegrationCount: offset,
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => {
const narrowedKind = kinds.find((allowedKind) => allowedKind === kind);
if (!narrowedKind) return throwUnexpectedIntegrationKind(kind, kinds);
return {
...rest,
externalUrl: rest.app?.href ?? null,
kind: narrowedKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
};
},
),
},
});
});
};
/**
* Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item
* It also ensures that the user has permission to perform the specified action on the integrations
@@ -92,55 +215,27 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
const dbIntegrations =
input.integrationIds.length >= 1
? await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: true,
groupPermissions: 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(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
return createManyIntegrationMiddlewareForKinds(action, kinds);
};
export const createOneWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
action,
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
);
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
action,
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
);
/**
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
* @param action action to perform
@@ -152,25 +247,39 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
const throwIfActionIsNotAllowedAsync = async (
action: IntegrationAction,
db: Database,
integrations: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
integrationRecords: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
session: Session | null,
) => {
if (action === "interact") {
const haveAllInteractAccess = integrations
const haveAllInteractAccess = integrationRecords
.map((integration) => constructIntegrationPermissions(integration, session))
.every(({ hasInteractAccess }) => hasInteractAccess);
if (haveAllInteractAccess) return;
logger.warn("Integration access denied", {
action,
userId: session?.user.id,
integrationIds: integrationRecords.map(({ id }) => id),
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
});
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to interact with at least one of the specified integrations",
});
}
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrationRecords, session);
if (hasQueryAccess) return;
logger.warn("Integration access denied", {
action,
userId: session?.user.id,
integrationIds: integrationRecords.map(({ id }) => id),
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
});
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to query at least one of the specified integration",
+15 -1
View File
@@ -5,9 +5,10 @@ import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferSelectModel } from "@homarr/db";
import { asc, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import { apps, integrations } from "@homarr/db/schema";
import { selectAppSchema } from "@homarr/db/validationSchemas";
import { getIconForName } from "@homarr/icons";
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
@@ -256,6 +257,14 @@ export const appRouter = createTRPCRouter({
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
})
.where(eq(apps.id, input.id));
if (app.href !== input.href) {
const linkedIntegrations = await ctx.db.query.integrations.findMany({
columns: { id: true },
where: eq(integrations.appId, input.id),
});
await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id)));
}
}),
delete: permissionRequiredProcedure
.requiresPermission("app-full-all")
@@ -271,7 +280,12 @@ export const appRouter = createTRPCRouter({
})
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
const linkedIntegrations = await ctx.db.query.integrations.findMany({
columns: { id: true },
where: eq(integrations.appId, input.id),
});
await ctx.db.delete(apps).where(eq(apps.id, input.id));
await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id)));
}),
});
+168 -130
View File
@@ -20,9 +20,7 @@ import {
groupMembers,
groupPermissions,
groups,
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
integrations,
apps,
itemLayouts,
@@ -71,7 +69,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
import { throwIfIntegrationActionsForbiddenAsync } from "./integration/integration-access";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
@@ -131,6 +129,75 @@ const findFirstAvailableBoardItemPosition = (
return null;
};
interface WidgetConfiguration {
id: string;
kind: WidgetKind;
integrationIds: readonly string[];
}
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => {
if (left.length !== right.length) return false;
const sortedRight = right.toSorted();
return left.toSorted().every((integrationId, index) => integrationId === sortedRight[index]);
};
const validateWidgetConfigurationsAsync = async (
ctx: Parameters<typeof throwIfIntegrationActionsForbiddenAsync>[0],
submittedItems: readonly WidgetConfiguration[],
storedItems: readonly WidgetConfiguration[] = [],
) => {
const storedItemsById = new Map(storedItems.map((item) => [item.id, item]));
const changedItems = submittedItems.filter((item) => {
const storedItem = storedItemsById.get(item.id);
if (!storedItem || storedItem.kind !== item.kind) return true;
return !haveSameIntegrationIds(item.integrationIds, storedItem.integrationIds);
});
if (changedItems.length === 0) return;
const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))];
let integrationRecords: { id: string; kind: IntegrationKind }[] = [];
if (selectedIntegrationIds.length > 0) {
integrationRecords = await ctx.db.query.integrations.findMany({
columns: { id: true, kind: true },
where: inArray(integrations.id, selectedIntegrationIds),
});
}
const integrationRecordsById = new Map(integrationRecords.map((integration) => [integration.id, integration]));
const invalidIntegrationIds = selectedIntegrationIds.filter(
(integrationId) => !integrationRecordsById.has(integrationId),
);
if (invalidIntegrationIds.length > 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
const addedOrReconfiguredIntegrationIds = [
...new Set(
changedItems.flatMap((item) => {
const storedItem = storedItemsById.get(item.id);
if (!storedItem || storedItem.kind !== item.kind) return item.integrationIds;
return item.integrationIds.filter((integrationId) => !storedItem.integrationIds.includes(integrationId));
}),
),
];
await throwIfIntegrationActionsForbiddenAsync(ctx, addedOrReconfiguredIntegrationIds, "use");
for (const item of changedItems) {
const selectedIntegrationKinds = item.integrationIds.flatMap(
(integrationId) => integrationRecordsById.get(integrationId)?.kind ?? [],
);
const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds);
if (!integrationIssue) continue;
throw new TRPCError({
code: "BAD_REQUEST",
message: getWidgetIntegrationIssueMessage(item.kind, integrationIssue),
});
}
};
const searchAccessibleBoardsAsync = async (
ctx: { db: Database; session: Session | null },
query: string,
@@ -169,11 +236,7 @@ const searchAccessibleBoardsAsync = async (
like(boards.name, `%${query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
: or(eq(boards.isPublic, true), eq(boards.creatorId, ctx.session?.user.id ?? ""), inArray(boards.id, boardIds)),
),
limit,
orderBy: asc(boards.name),
@@ -659,7 +722,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
},
})
.input(boardDuplicateSchema)
@@ -696,6 +759,14 @@ export const boardRouter = createTRPCRouter({
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
await validateWidgetConfigurationsAsync(
ctx,
boardItems.map((item) => ({
id: item.id,
kind: item.kind,
integrationIds: item.integrations.map(({ integrationId }) => integrationId),
})),
);
const newBoardId = createId();
@@ -776,17 +847,19 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
.flatMap((section) =>
section.layouts.map((layoutSection): InferInsertModel<typeof sectionLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
: layoutSection.parentSectionId,
})),
section.layouts.map(
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
: layoutSection.parentSectionId,
}),
),
)
.concat(
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
@@ -803,11 +876,13 @@ export const boardRouter = createTRPCRouter({
);
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
(section) =>
section.collapseStates.map((collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
...collapseState,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(collapseState.sectionId)!,
})),
section.collapseStates.map(
(collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
...collapseState,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(collapseState.sectionId)!,
}),
),
);
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
@@ -822,15 +897,17 @@ export const boardRouter = createTRPCRouter({
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
.flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
})),
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
}),
),
)
.concat(
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
@@ -844,34 +921,12 @@ export const boardRouter = createTRPCRouter({
})),
);
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
const integrationIdsWithAccess = hasAccessForAll
? []
: await ctx.db
.selectDistinct({
id: integrationGroupPermissions.integrationId,
})
.from(integrationGroupPermissions)
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, ctx.session.user.id))
.union(
ctx.db
.selectDistinct({ id: integrationUserPermissions.integrationId })
.from(integrationUserPermissions)
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
)
.then((result) => result.map((row) => row.id));
const itemIntegrationsToInsert = boardItems.flatMap((item) =>
item.integrations
// Restrict integrations to only those the user has access to
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
.map((integration) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(item.id)!,
integrationId: integration.integrationId,
})),
item.integrations.map((integration) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(item.id)!,
integrationId: integration.integrationId,
})),
);
await handleTransactionsAsync(ctx.db, {
@@ -1514,6 +1569,7 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
@@ -1523,10 +1579,9 @@ export const boardRouter = createTRPCRouter({
for (const item of input.items) {
if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
await validateTimetableOptionsChangeAsync(
item.options,
previousItem?.kind === "timetable" ? previousItem.options : undefined,
);
let previousOptions: Record<string, unknown> | undefined;
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
}
await handleTransactionsAsync(ctx.db, {
@@ -1550,15 +1605,17 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map((sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
})),
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
@@ -1580,15 +1637,17 @@ export const boardRouter = createTRPCRouter({
);
await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
})),
item.layouts.map(
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
);
}
@@ -1746,15 +1805,17 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map((sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
})),
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
@@ -1781,15 +1842,17 @@ export const boardRouter = createTRPCRouter({
.insert(itemLayouts)
.values(
addedItems.flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
})),
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
)
.run();
@@ -2092,6 +2155,10 @@ export const boardRouter = createTRPCRouter({
.output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
await validateWidgetConfigurationsAsync(ctx, [
{ id: "", kind: input.kind, integrationIds: input.integrationIds },
]);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
@@ -2102,35 +2169,6 @@ export const boardRouter = createTRPCRouter({
await validateTimetableOptionsChangeAsync(input.options);
}
let selectedIntegrationKinds: IntegrationKind[] = [];
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
columns: { id: true, kind: true },
where: inArray(integrations.id, input.integrationIds),
});
const validIds = new Set(existing.map((row) => row.id));
const invalid = input.integrationIds.filter((id) => !validIds.has(id));
if (invalid.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
}
selectedIntegrationKinds = existing.map((integration) => integration.kind);
await Promise.all(
input.integrationIds.map((integrationId) =>
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
),
);
}
const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds);
if (integrationIssue) {
throw new TRPCError({
code: "BAD_REQUEST",
message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue),
});
}
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
@@ -245,8 +245,8 @@ type DockerContainerAction = "start" | "stop" | "restart" | "remove";
export const performDockerContainerActionsAsync = async (
targets: DockerContainerTarget[],
action: DockerContainerAction,
) =>
await Promise.all(
) => {
const results = await Promise.all(
targets.map(async (target) => {
try {
const capability = action === "remove" ? "remove" : "lifecycle";
@@ -271,6 +271,13 @@ export const performDockerContainerActionsAsync = async (
}),
);
if (results.some(({ success }) => success)) {
dockerContainersRequestHandler.invalidateCache();
}
return results;
};
const assertDockerEndpointCapability = (
endpointId: string,
capability: "inventory" | "logs" | "lifecycle" | "remove",
@@ -1,12 +1,59 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import type { IntegrationPermissionsProps } from "@homarr/auth/shared";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions";
const hasIntegrationPermission = (
integration: IntegrationPermissionsProps,
session: Session | null,
permission: IntegrationPermission,
) => {
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) return true;
if (permission === "interact") return hasInteractAccess;
if (permission === "use") return hasUseAccess || hasInteractAccess;
return false;
};
const throwIfActionsForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationWhere: SQL<unknown>,
expectedCount: number,
permission: IntegrationPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integrationRecords = await db.query.integrations.findMany({
where: integrationWhere,
columns: { id: true },
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map(({ groupId }) => groupId).concat(""),
),
},
},
});
if (
integrationRecords.length !== expectedCount ||
!integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission))
) {
notAllowed();
}
};
/**
* Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context
@@ -18,47 +65,23 @@ export const throwIfActionForbiddenAsync = async (
integrationWhere: SQL<unknown>,
permission: IntegrationPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integration = await db.query.integrations.findFirst({
where: integrationWhere,
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission);
};
if (!integration) {
notAllowed();
}
export const throwIfIntegrationActionsForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationIds: readonly string[],
permission: IntegrationPermission,
) => {
const uniqueIntegrationIds = [...new Set(integrationIds)];
if (uniqueIntegrationIds.length === 0) return;
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
}
if (["interact", "use"].includes(permission) && hasInteractAccess) {
return; // As interact access is required and user has interact access, allow
}
if (permission === "use" && hasUseAccess) {
return; // As use access is required and user has use access, allow
}
notAllowed();
await throwIfActionsForbiddenAsync(
ctx,
inArray(integrations.id, uniqueIntegrationIds),
uniqueIntegrationIds.length,
permission,
);
};
/**
@@ -27,8 +27,10 @@ import {
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { byIdSchema } from "@homarr/validation/common";
import {
integrationCreateSchema,
@@ -156,9 +158,21 @@ export const integrationRouter = createTRPCRouter({
})
.input(byIdSchema)
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integrationWhere = eq(integrations.id, input.id);
await throwIfActionForbiddenAsync(ctx, integrationWhere, "full").catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
logger.warn("Integration lookup refused", {
integrationId: input.id,
errorCode: error.code,
reason: "integration-not-found-or-forbidden",
});
}
throw error;
});
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
where: integrationWhere,
with: {
secrets: {
columns: {
@@ -179,6 +193,11 @@ export const integrationRouter = createTRPCRouter({
});
if (!integration) {
logger.warn("Integration lookup refused", {
integrationId: input.id,
errorCode: "NOT_FOUND",
reason: "integration-not-found",
});
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
@@ -695,7 +714,17 @@ export const integrationRouter = createTRPCRouter({
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
try {
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([ctx.integration.id]);
} catch (error) {
logger.warn("Failed to invalidate media request caches after a successful request", {
integrationId: ctx.integration.id,
error: String(error),
});
}
return result;
}),
});
@@ -4,7 +4,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
const logger = createLogger({ module: "integrationTestConnection" });
@@ -218,6 +218,7 @@ export const onboardRouter = createTRPCRouter({
const discoveredApps: DiscoveredApp[] = [];
for (const service of discovery.services) {
const widgetKind = service.widgetKind;
if (service.integrationKind) {
discoveredIntegrations.push({
sourceId: service.sourceId,
@@ -231,7 +232,7 @@ export const onboardRouter = createTRPCRouter({
group: service.group,
boardName: service.boardName,
host: service.host,
widgetKind: service.widgetKind,
widgetKind,
description: service.description,
pingUrl: service.pingUrl,
});
@@ -249,7 +250,7 @@ export const onboardRouter = createTRPCRouter({
description: service.description,
pingUrl: service.pingUrl,
host: service.host,
widgetKind: service.widgetKind,
widgetKind,
});
}
}
+16 -7
View File
@@ -41,6 +41,14 @@ const defaultSession = {
expires: new Date().toISOString(),
} satisfies Session;
const integrationUseSession = {
...defaultSession,
user: {
...defaultSession.user,
permissions: ["integration-use-all"],
},
} satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
@@ -698,7 +706,8 @@ describe("duplicateBoard should preserve protected layout invariants", () => {
},
};
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
const { boardId } = await createFullBoardAsync(db, "source");
const { boardId, integrationId } = await createFullBoardAsync(db, "source");
await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId));
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
@@ -1195,7 +1204,7 @@ describe("saveBoard should save full board", () => {
it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
const anotherIntegration = {
id: createId(),
kind: "adGuardHome",
@@ -1219,8 +1228,8 @@ describe("saveBoard should save full board", () => {
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
kind: "dnsHoleSummary",
options: { usePiHoleColors: true, layout: "grid" },
integrationIds: [anotherIntegration.id],
layouts: [
{
@@ -1526,7 +1535,7 @@ describe("saveBoard should save full board", () => {
it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
const integration = {
id: createId(),
kind: "plex",
@@ -1550,8 +1559,8 @@ describe("saveBoard should save full board", () => {
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
kind: "mediaServer",
options: { showOnlyPlaying: true, showBitrate: true, showLocation: true },
integrationIds: [integration.id],
layouts: [
{
@@ -1,7 +1,8 @@
import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions";
import * as homarrIntegrations from "@homarr/integrations";
import type * as homarrIntegrations from "@homarr/integrations";
import * as homarrIntegrationFactory from "@homarr/integrations/factory";
import { testConnectionAsync } from "../../integration/integration-test-connection";
@@ -18,7 +19,7 @@ vi.mock("@homarr/common/server", async (importActual) => {
describe("testConnectionAsync should run test connection of integration", () => {
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -61,7 +62,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -111,7 +112,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -161,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -215,7 +216,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -3,15 +3,12 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
anchorNotesListInputSchema,
anchorNoteUpdateInputSchema,
createIntegrationAsync,
mockWidgetData,
} from "@homarr/integrations";
import { mockWidgetData } from "@homarr/integrations";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({
@@ -51,7 +48,7 @@ const normalizeAnchorContent = (content: string | undefined) => {
export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
@@ -73,7 +70,7 @@ export const anchorNotesRouter = createTRPCRouter({
return data;
}),
getNote: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
@@ -91,7 +88,7 @@ export const anchorNotesRouter = createTRPCRouter({
return data;
}),
updateNote: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "anchor", "mock"))
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const normalizedContent = normalizeAnchorContent(input.content);
@@ -115,6 +112,8 @@ export const anchorNotesRouter = createTRPCRouter({
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
anchorNotesListRequestHandler.invalidateCache();
await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]);
return updatedNote;
} catch (error) {
@@ -1,12 +1,12 @@
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) };
@@ -2,12 +2,12 @@ import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobooks
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
.query(async ({ ctx }) => {
const { kind } = ctx.integration;
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
+2 -2
View File
@@ -1,7 +1,7 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const bazarrRouter = createTRPCRouter({
@@ -13,7 +13,7 @@ export const bazarrRouter = createTRPCRouter({
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
},
})
.concat(createOneIntegrationMiddleware("query", "bazarr", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
+6 -6
View File
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { LiveStatsEvent } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
@@ -11,7 +11,7 @@ import {
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -27,7 +27,7 @@ export const beszelRouter = createTRPCRouter({
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid"))
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
@@ -71,7 +71,7 @@ export const beszelRouter = createTRPCRouter({
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelAlerts"))
.input(
z.object({
includeHistory: z.boolean().default(true),
@@ -145,7 +145,7 @@ export const beszelRouter = createTRPCRouter({
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input(
z.object({
systemId: z.string(),
@@ -196,7 +196,7 @@ export const beszelRouter = createTRPCRouter({
}),
subscribeSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input(
z.object({
systemId: z.string(),
+3 -4
View File
@@ -1,10 +1,9 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -25,7 +24,7 @@ export const calendarRouter = createTRPCRouter({
showUnmonitored: z.boolean(),
}),
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.concat(createManyWidgetIntegrationMiddleware("query", "calendar"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -44,7 +43,7 @@ export const calendarRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
},
error: undefined as string | undefined,
error: undefined,
};
},
{
+2 -2
View File
@@ -2,7 +2,7 @@ import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { mockWidgetData } from "@homarr/integrations";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -17,7 +17,7 @@ interface CoolifyInstanceResult {
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+10 -6
View File
@@ -1,12 +1,14 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -32,7 +34,7 @@ export const dnsHoleRouter = createTRPCRouter({
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
ctx.integrations,
@@ -66,10 +68,11 @@ export const dnsHoleRouter = createTRPCRouter({
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
disable: protectedProcedure
@@ -85,9 +88,10 @@ export const dnsHoleRouter = createTRPCRouter({
duration: z.number().optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
});
+27 -23
View File
@@ -1,19 +1,15 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientItemSchema } from "@homarr/integrations/downloads";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
interface DownloadClientQueryResultBase {
integrationId: string;
integrationName: string;
@@ -42,6 +38,14 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase &
}
);
const runDownloadMutationsAsync = async (mutations: Promise<void>[], integrationIds: string[]) => {
const results = await Promise.allSettled(mutations);
await downloadClientRequestHandler.invalidateCacheAsync(integrationIds);
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
};
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
.meta({
@@ -51,7 +55,7 @@ export const downloadsRouter = createTRPCRouter({
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
},
})
.concat(createDownloadClientIntegrationMiddleware("query"))
.concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
@@ -86,27 +90,27 @@ export const downloadsRouter = createTRPCRouter({
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseItemAsync(input.item);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
resume: protectedProcedure
.meta({
@@ -116,38 +120,38 @@ export const downloadsRouter = createTRPCRouter({
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeItemAsync(input.item);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
});
+23 -24
View File
@@ -1,5 +1,3 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
firewallCpuRequestHandler,
@@ -8,36 +6,37 @@ import {
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware(
"query",
...getIntegrationKindsByCategory("firewall"),
"mock",
);
const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
interface FirewallResult<TSummary> {
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
summary: TSummary;
error?: string;
type FirewallIntegration = Parameters<typeof firewallCpuRequestHandler.handler>[0];
interface FirewallHandler<TData> {
handler: (
integration: FirewallIntegration,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> };
}
const queryFirewall = <TSummary>(
handler: {
handler: (
integration: any,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> };
},
createFallback: () => TSummary,
getMockData: () => TSummary,
) =>
interface FirewallQueryResult<TData> {
integration: {
id: string;
name: string;
kind: FirewallIntegration["kind"] | "mock";
updatedAt: Date;
};
summary: TData;
error: string | undefined;
}
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData, getMockData: () => TData) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<FirewallResult<TSummary>> => {
async (integration): Promise<FirewallQueryResult<TData>> => {
if (integration.kind === "mock") {
return {
integration: {
@@ -58,7 +57,7 @@ const queryFirewall = <TSummary>(
};
},
{
fallback: (integration, error): FirewallResult<TSummary> => ({
fallback: (integration, error): FirewallQueryResult<TData> => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: toPublicIntegrationError(error),
@@ -1,22 +1,12 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
"openmediavault",
"dashDot",
"truenas",
"unraid",
"glances",
"synology",
"mock",
] as const;
interface SystemHealthQueryResult {
integrationId: string;
integrationName: string;
@@ -34,7 +24,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringSystemIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
ctx.integrations,
@@ -69,10 +59,6 @@ export const healthMonitoringRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "synology"))
.query(async ({ ctx }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
if (!(integrationInstance instanceof SynologyIntegration)) {
throw new Error("Expected Synology integration");
}
return await integrationInstance.listStorageVolumesAsync();
}),
getClusterHealthStatus: publicProcedure
@@ -83,7 +69,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.concat(createOneIntegrationMiddleware("query", ...healthMonitoringClusterIntegrationKinds))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
+11 -14
View File
@@ -1,6 +1,5 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
immichAlbumRequestHandler,
@@ -8,23 +7,21 @@ import {
immichStatsRequestHandler,
} from "@homarr/request-handler/immich";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock");
export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getServerStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbum: publicProcedure
.concat(createImmichIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.input(
z.object({
albumId: z.string().optional(),
@@ -48,7 +45,7 @@ export const immichRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500).optional(),
}),
)
.concat(createImmichIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
@@ -1,20 +1,15 @@
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.concat(createManyWidgetIntegrationMiddleware("query", "indexerManager"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -26,7 +21,7 @@ export const indexerManagerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
indexers,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -41,9 +36,9 @@ export const indexerManagerRouter = createTRPCRouter({
);
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager"))
.mutation(async ({ ctx }) => {
await Promise.all(
const results = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const client = await createIntegrationAsync(integration);
await client.testAllAsync().catch((err) => {
@@ -55,5 +50,9 @@ export const indexerManagerRouter = createTRPCRouter({
});
}),
);
await indexerManagerRequestHandler.invalidateCacheAsync(ctx.integrations.map(({ id }) => id));
const firstFailure = results.find((result) => result.status === "rejected");
if (firstFailure?.status === "rejected") throw firstFailure.reason;
}),
});
@@ -1,9 +1,8 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -16,7 +15,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaMissing"))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
@@ -28,7 +27,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
...data,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -1,13 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { getMediaReleaseResponse } from "./media-release-result";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases"))
.query(async ({ ctx }) => {
return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
@@ -1,8 +1,7 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import {
@@ -11,7 +10,10 @@ import {
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -30,7 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList"))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
@@ -40,7 +42,7 @@ export const mediaRequestsRouter = createTRPCRouter({
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -85,7 +87,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats"))
.query(async ({ ctx }) => {
const results: IntegrationQueryResult<{
stats: MediaRequestStats["stats"] | null;
@@ -96,8 +98,8 @@ export const mediaRequestsRouter = createTRPCRouter({
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { ...data, stats: data.stats as typeof data.stats | null },
error: undefined as string | undefined,
data,
error: undefined,
};
},
{
@@ -134,7 +136,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList"))
.input(
z.object({
requestId: z.number(),
@@ -150,6 +152,8 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]);
return { success: true };
}),
});
@@ -1,16 +1,11 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure
.meta({
@@ -20,7 +15,7 @@ export const mediaServerRouter = createTRPCRouter({
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
},
})
.concat(createMediaServerIntegrationMiddleware("query"))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
@@ -34,7 +29,7 @@ export const mediaServerRouter = createTRPCRouter({
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -1,17 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { paginatedSchema } from "@homarr/validation/common";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
export const mediaTranscodingRouter = createTRPCRouter({
getDataAsync: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding"))
.input(paginatedSchema.pick({ page: true, pageSize: true }))
.query(async ({ ctx, input }) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
@@ -1,9 +1,8 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -22,7 +21,7 @@ interface NetworkControllerQueryResult {
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
ctx.integrations,
@@ -1,17 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "notifications"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -27,7 +22,7 @@ export const notificationsRouter = createTRPCRouter({
updatedAt: timestamp,
},
data,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -1,12 +1,12 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
+2 -2
View File
@@ -1,7 +1,7 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const patchmonRouter = createTRPCRouter({
@@ -13,7 +13,7 @@ export const patchmonRouter = createTRPCRouter({
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
},
})
.concat(createOneIntegrationMiddleware("query", "patchmon", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "patchmon"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
+8 -11
View File
@@ -1,20 +1,15 @@
import { z } from "zod/v4";
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import {
smartHomeEntityStateRequestHandler,
toSafeEntityDetails,
} from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure
.meta({
@@ -25,7 +20,7 @@ export const smartHomeRouter = createTRPCRouter({
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
@@ -40,7 +35,7 @@ export const smartHomeRouter = createTRPCRouter({
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
@@ -54,7 +49,7 @@ export const smartHomeRouter = createTRPCRouter({
"Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-entityState"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
@@ -62,6 +57,7 @@ export const smartHomeRouter = createTRPCRouter({
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
}
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true;
}),
executeAutomation: protectedProcedure
@@ -72,7 +68,7 @@ export const smartHomeRouter = createTRPCRouter({
"Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-executeAutomation"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
@@ -80,6 +76,7 @@ export const smartHomeRouter = createTRPCRouter({
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
}
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true;
}),
});
@@ -1,13 +1,13 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -29,9 +29,9 @@ export const speedtestTrackerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
dashboard: data,
updatedAt: timestamp,
error: undefined as string | undefined,
error: undefined,
};
},
{
+2 -2
View File
@@ -1,13 +1,13 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "tracearr", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "tracearr"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+4 -4
View File
@@ -1,7 +1,7 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -14,7 +14,7 @@ export const traefikRouter = createTRPCRouter({
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
},
})
.concat(createManyIntegrationMiddleware("query", "traefik", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "traefik"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -36,9 +36,9 @@ export const traefikRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
dashboard: data,
updatedAt: timestamp,
error: undefined as string | undefined,
error: undefined,
};
},
{
+22 -20
View File
@@ -2,7 +2,8 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createIntegrationAsync, mockWidgetData } from "@homarr/integrations";
import { mockWidgetData } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
@@ -12,25 +13,26 @@ import {
umamiTopReferrersRequestHandler,
} from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
try {
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
try {
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getVisitorStats: publicProcedure
.input(
@@ -40,7 +42,7 @@ export const umamiRouter = createTRPCRouter({
eventName: z.string().optional(),
}),
)
.concat(createManyIntegrationMiddleware("query", "umami", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
if (integration.kind === "mock") {
@@ -74,7 +76,7 @@ export const umamiRouter = createTRPCRouter({
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
try {
@@ -102,7 +104,7 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
try {
@@ -136,7 +138,7 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
try {
@@ -170,7 +172,7 @@ export const umamiRouter = createTRPCRouter({
eventNames: z.array(z.string()),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
@@ -201,7 +203,7 @@ export const umamiRouter = createTRPCRouter({
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return 7;
try {
+29 -32
View File
@@ -1,40 +1,37 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
getSummaries: publicProcedure.concat(createManyWidgetIntegrationMiddleware("query", "ups")).query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: [],
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
error: undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: [],
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -1,13 +1,13 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -29,9 +29,9 @@ export const uptimeKumaRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
dashboard: data,
updatedAt: timestamp,
error: undefined as string | undefined,
error: undefined,
};
},
{
+4 -5
View File
@@ -1,10 +1,9 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -16,7 +15,7 @@ interface VpnResult {
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"), "mock"))
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -43,8 +42,8 @@ export const vpnRouter = createTRPCRouter({
kind: integration.kind,
updatedAt: timestamp,
},
summary: data as typeof data | null,
error: undefined as string | undefined,
summary: data,
error: undefined,
};
},
{
+2 -2
View File
@@ -1,7 +1,7 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const wudRouter = createTRPCRouter({
@@ -13,7 +13,7 @@ export const wudRouter = createTRPCRouter({
"Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "wud", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) };
+6 -6
View File
@@ -15,18 +15,18 @@ interface IntegrationLike {
kind: string;
}
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
interface Options<TIntegration extends IntegrationLike, TFallback> {
fallback?: (integration: TIntegration, error: unknown) => TFallback;
throwOnAllFailures?: boolean;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult, TFallback = TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
options?: Options<TIntegration, TFallback>,
): Promise<(TResult | TFallback)[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
const results: TResult[] = [];
const results: (TResult | TFallback)[] = [];
const errors: unknown[] = [];
settled.forEach((result, index) => {