refactor: simplify widget and integration architecture

This commit is contained in:
ajnart committed 2026-08-24 00:57:36 +02:00
1 parent c988c35b29
commit 8f9efd0074
179 files changed
+1509 -4187

No files matched your search

@@ -75,6 +75,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) {
+4 -22
View File
@@ -11,10 +11,7 @@ import {
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import {
createManySharedWidgetIntegrationMiddleware,
createManyWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -30,12 +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(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemGrid", [
"beszelSystemTable",
"beszelSystemStats",
]),
)
.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 });
@@ -153,12 +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(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
"beszelSystemTable",
"beszelSystemGrid",
]),
)
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input(
z.object({
systemId: z.string(),
@@ -209,12 +196,7 @@ export const beszelRouter = createTRPCRouter({
}),
subscribeSystemStats: publicProcedure
.concat(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
"beszelSystemTable",
"beszelSystemGrid",
]),
)
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input(
z.object({
systemId: z.string(),
+4 -2
View File
@@ -6,7 +6,7 @@ import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import {
createManySharedWidgetIntegrationMiddleware,
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
@@ -34,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(createManySharedWidgetIntegrationMiddleware("query", "dnsHoleSummary", ["dnsHoleControls"]))
.concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
ctx.integrations,
@@ -72,6 +72,7 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
disable: protectedProcedure
@@ -91,5 +92,6 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
});
+18 -10
View File
@@ -38,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({
@@ -84,25 +92,25 @@ export const downloadsRouter = createTRPCRouter({
})
.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(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({
@@ -114,36 +122,36 @@ export const downloadsRouter = createTRPCRouter({
})
.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(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(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();
}),
});
@@ -1,24 +0,0 @@
// This file is generated by pnpm widgets:sync. Do not edit it directly.
import type { AnyTRPCRouter } from "@trpc/server";
import type { WidgetKind } from "@homarr/definitions";
const registerGeneratedWidgetRouter = <TRouter extends AnyTRPCRouter>(
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
loadRouter: () => Promise<TRouter>,
mcp: false | true | string,
) => ({ widgetKinds, loadRouter, mcp });
export const generatedWidgetRouterRegistry = {
downloads: registerGeneratedWidgetRouter(
["downloads"],
() => import("./downloads").then((module) => module.downloadsRouter),
true,
),
weather: registerGeneratedWidgetRouter(
["clock", "weather"],
() => import("./weather").then((module) => module.weatherRouter),
false,
),
};
@@ -2,11 +2,7 @@ import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import {
createManySharedWidgetIntegrationMiddleware,
createOneSharedWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -39,14 +35,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(
createManySharedWidgetIntegrationMiddleware(
"query",
"healthMonitoring",
["systemResources", "systemDisks"],
healthMonitoringIntegrationKinds,
),
)
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
ctx.integrations,
@@ -78,7 +67,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
},
})
.concat(createOneSharedWidgetIntegrationMiddleware("query", "healthMonitoring", ["systemDisks"], ["synology"]))
.concat(createOneIntegrationMiddleware("query", "synology"))
.query(async ({ ctx }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.listStorageVolumesAsync();
@@ -91,7 +80,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(createOneWidgetIntegrationMiddleware("query", "healthMonitoring", ["proxmox", "mock"]))
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
+2 -5
View File
@@ -6,10 +6,7 @@ import {
immichStatsRequestHandler,
} from "@homarr/request-handler/immich";
import {
createOneSharedWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const immichRouter = createTRPCRouter({
@@ -40,7 +37,7 @@ export const immichRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500).optional(),
}),
)
.concat(createOneSharedWidgetIntegrationMiddleware("query", "immich-albumCarousel", ["immich-serverStats"]))
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
const data = await innerHandler.getDataAsync();
+42 -23
View File
@@ -1,30 +1,49 @@
import type { AnyTRPCRouter } from "@trpc/server";
import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
import { widgetRouterRegistry } from "./registry";
import type { WidgetRouterName } from "./registry";
const createLazyWidgetRouter = <TRouter extends AnyTRPCRouter>(loadRouter: () => Promise<TRouter>) => lazy(loadRouter);
const lazyWidgetRouters = Object.fromEntries(
Object.entries(widgetRouterRegistry).map(([name, registration]) => [
name,
createLazyWidgetRouter(registration.loadRouter as () => Promise<AnyTRPCRouter>),
]),
) as unknown as {
[TName in WidgetRouterName]: ReturnType<
typeof createLazyWidgetRouter<
(typeof widgetRouterRegistry)[TName] extends { loadRouter: () => Promise<infer TRouter> }
? TRouter extends AnyTRPCRouter
? TRouter
: never
: never
>
>;
};
export const widgetRouter = createTRPCRouter({
...lazyWidgetRouters,
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
stockPrice: lazy(() => import("./stocks").then((mod) => mod.stockPriceRouter)),
mediaServer: lazy(() => import("./media-server").then((mod) => mod.mediaServerRouter)),
mediaRelease: lazy(() => import("./media-release").then((mod) => mod.mediaReleaseRouter)),
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
mediaTranscoding: lazy(() => import("./media-transcoding").then((mod) => mod.mediaTranscodingRouter)),
minecraft: lazy(() => import("./minecraft").then((mod) => mod.minecraftRouter)),
options: lazy(() => import("./options").then((mod) => mod.optionsRouter)),
releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)),
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
});
@@ -152,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 };
}),
});
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -15,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(createManySharedWidgetIntegrationMiddleware("query", "mediaServer", ["audioStats"]))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
@@ -2,7 +2,7 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -21,9 +21,7 @@ interface NetworkControllerQueryResult {
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(
createManySharedWidgetIntegrationMiddleware("query", "networkControllerSummary", ["networkControllerStatus"]),
)
.concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
ctx.integrations,
-225
View File
@@ -1,225 +0,0 @@
import type { AnyTRPCRouter } from "@trpc/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { generatedWidgetRouterRegistry } from "./generated-registry";
const {
weather: generatedWeatherRouter,
downloads: generatedDownloadsRouter,
...additionalGeneratedWidgetRouters
} = generatedWidgetRouterRegistry;
const logger = createLogger({ module: "widgetRouterRegistry" });
type McpRouterNamespace = false | true | string;
type WidgetRouterRegistration<TRouter extends AnyTRPCRouter, TMcpNamespace extends McpRouterNamespace> = {
widgetKinds: readonly [WidgetKind, ...WidgetKind[]];
loadRouter: () => Promise<TRouter>;
mcp: TMcpNamespace;
};
const registerWidgetRouter = <TRouter extends AnyTRPCRouter, const TMcpNamespace extends McpRouterNamespace = false>(
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
loadRouter: () => Promise<TRouter>,
options?: { mcp?: TMcpNamespace },
): WidgetRouterRegistration<TRouter, TMcpNamespace> => ({
widgetKinds,
loadRouter,
mcp: (options?.mcp ?? false) as TMcpNamespace,
});
/**
* Shared namespace-to-feature contract for widget tRPC consumers.
*
* Keep import paths explicit so Next.js can split every implementation while
* consumers share one widget-to-router association.
*/
export const widgetRouterRegistry = {
archiveTeamWarrior: registerWidgetRouter(["archiveTeamWarrior"], () =>
import("./archive-team-warrior").then((module) => module.archiveTeamWarriorRouter),
),
beszel: registerWidgetRouter(
["beszelSystemTable", "beszelSystemGrid", "beszelAlerts", "beszelSystemStats"],
() => import("./beszel").then((module) => module.beszelRouter),
{ mcp: true },
),
anchorNotes: registerWidgetRouter(["anchorNote"], () =>
import("./anchor-notes").then((module) => module.anchorNotesRouter),
),
coolify: registerWidgetRouter(["coolify"], () => import("./coolify").then((module) => module.coolifyRouter)),
immich: registerWidgetRouter(["immich-serverStats", "immich-albumCarousel"], () =>
import("./immich").then((module) => module.immichRouter),
),
paperlessNgx: registerWidgetRouter(["paperlessNgx"], () =>
import("./paperless-ngx").then((module) => module.paperlessNgxRouter),
),
patchmon: registerWidgetRouter(["patchmon"], () => import("./patchmon").then((module) => module.patchmonRouter), {
mcp: true,
}),
bazarr: registerWidgetRouter(["bazarr"], () => import("./bazarr").then((module) => module.bazarrRouter), {
mcp: true,
}),
notebook: registerWidgetRouter(["notebook"], () => import("./notebook").then((module) => module.notebookRouter)),
weather: generatedWeatherRouter,
airQuality: registerWidgetRouter(
["airQuality"],
() => import("./air-quality").then((module) => module.airQualityRouter),
{ mcp: true },
),
app: registerWidgetRouter(["app"], () => import("./app").then((module) => module.appRouter)),
dnsHole: registerWidgetRouter(
["dnsHoleSummary", "dnsHoleControls"],
() => import("./dns-hole").then((module) => module.dnsHoleRouter),
{ mcp: true },
),
smartHome: registerWidgetRouter(
["smartHome-entityState", "smartHome-executeAutomation"],
() => import("./smart-home").then((module) => module.smartHomeRouter),
{ mcp: true },
),
stockPrice: registerWidgetRouter(["stockPrice"], () => import("./stocks").then((module) => module.stockPriceRouter)),
mediaServer: registerWidgetRouter(
["mediaServer", "audioStats"],
() => import("./media-server").then((module) => module.mediaServerRouter),
{ mcp: true },
),
mediaRelease: registerWidgetRouter(["mediaReleases"], () =>
import("./media-release").then((module) => module.mediaReleaseRouter),
),
calendar: registerWidgetRouter(["calendar"], () => import("./calendar").then((module) => module.calendarRouter), {
mcp: true,
}),
downloads: generatedDownloadsRouter,
mediaRequests: registerWidgetRouter(
["mediaRequests-requestList", "mediaRequests-requestStats"],
() => import("./media-requests").then((module) => module.mediaRequestsRouter),
{ mcp: true },
),
mediaOrganizer: registerWidgetRouter(["mediaMissing"], () =>
import("./media-organizer").then((module) => module.mediaOrganizerRouter),
),
rssFeed: registerWidgetRouter(["rssFeed"], () => import("./rssFeed").then((module) => module.rssFeedRouter)),
indexerManager: registerWidgetRouter(["indexerManager"], () =>
import("./indexer-manager").then((module) => module.indexerManagerRouter),
),
healthMonitoring: registerWidgetRouter(
["healthMonitoring", "systemResources", "systemDisks"],
() => import("./health-monitoring").then((module) => module.healthMonitoringRouter),
{ mcp: true },
),
mediaTranscoding: registerWidgetRouter(["mediaTranscoding"], () =>
import("./media-transcoding").then((module) => module.mediaTranscodingRouter),
),
minecraft: registerWidgetRouter(["minecraftServerStatus"], () =>
import("./minecraft").then((module) => module.minecraftRouter),
),
releases: registerWidgetRouter(["releases"], () => import("./releases").then((module) => module.releasesRouter)),
networkController: registerWidgetRouter(["networkControllerSummary", "networkControllerStatus"], () =>
import("./network-controller").then((module) => module.networkControllerRouter),
),
firewall: registerWidgetRouter(["firewall"], () => import("./firewall").then((module) => module.firewallRouter)),
notifications: registerWidgetRouter(["notifications"], () =>
import("./notifications").then((module) => module.notificationsRouter),
),
timetable: registerWidgetRouter(["timetable"], () => import("./timetable").then((module) => module.timetableRouter)),
tracearr: registerWidgetRouter(["tracearr"], () => import("./tracearr").then((module) => module.tracearrRouter)),
speedtestTracker: registerWidgetRouter(["speedtestTracker"], () =>
import("./speedtest-tracker").then((module) => module.speedtestTrackerRouter),
),
uptimeKuma: registerWidgetRouter(["uptimeKuma"], () =>
import("./uptime-kuma").then((module) => module.uptimeKumaRouter),
),
audioStats: registerWidgetRouter(["audioStats"], () =>
import("./audio-stats").then((module) => module.audioStatsRouter),
),
umami: registerWidgetRouter(["umami"], () => import("./umami").then((module) => module.umamiRouter)),
vpn: registerWidgetRouter(["vpn"], () => import("./vpn").then((module) => module.vpnRouter)),
ups: registerWidgetRouter(["ups"], () => import("./ups").then((module) => module.upsRouter)),
traefik: registerWidgetRouter(["traefik"], () => import("./traefik").then((module) => module.traefikRouter)),
customApi: registerWidgetRouter(["customApi"], () => import("./custom-api").then((module) => module.customApiRouter)),
secrets: registerWidgetRouter(
["releases"],
() => import("./widget-secrets").then((module) => module.widgetSecretsRouter),
{ mcp: "widgetSecrets" },
),
wud: registerWidgetRouter(["wud"], () => import("./wud").then((module) => module.wudRouter)),
...additionalGeneratedWidgetRouters,
};
export type WidgetRouterName = keyof typeof widgetRouterRegistry;
type LoadedWidgetRouterRecord = {
[TName in WidgetRouterName]: Awaited<ReturnType<(typeof widgetRouterRegistry)[TName]["loadRouter"]>>;
};
interface McpWidgetRouterSelection {
name: WidgetRouterName;
namespace: string;
}
export const mcpWidgetRouterSelections: readonly McpWidgetRouterSelection[] = Object.entries(
widgetRouterRegistry,
).flatMap(([name, registration]) => {
if (!registration.mcp) return [];
let namespace = name;
if (typeof registration.mcp === "string") namespace = registration.mcp;
return [{ name: name as WidgetRouterName, namespace }];
});
export const mcpWidgetRouterNames: readonly WidgetRouterName[] = mcpWidgetRouterSelections.map(
(selection) => selection.name,
);
export async function loadWidgetRoutersAsync<const TNames extends readonly WidgetRouterName[]>(
names: TNames,
consumer: string,
): Promise<Pick<LoadedWidgetRouterRecord, TNames[number]>> {
const loadedRouters: Partial<Record<WidgetRouterName, AnyTRPCRouter>> = {};
await Promise.all(
names.map(async (name) => {
const registration = widgetRouterRegistry[name];
const startedAt = Date.now();
try {
loadedRouters[name] = await registration.loadRouter();
logger.debug("Loaded widget router", {
consumer,
routerName: name,
widgetKinds: registration.widgetKinds,
durationMs: Date.now() - startedAt,
});
} catch (error) {
logger.error(
new ErrorWithMetadata(
"Failed to load widget router",
{
consumer,
routerName: name,
widgetKinds: registration.widgetKinds,
durationMs: Date.now() - startedAt,
},
{ cause: error },
),
);
throw error;
}
}),
);
return loadedRouters as Pick<LoadedWidgetRouterRecord, TNames[number]>;
}
export async function loadMcpWidgetRoutersAsync(): Promise<Record<string, AnyTRPCRouter>> {
const loadedRouters = await loadWidgetRoutersAsync(mcpWidgetRouterNames, "mcp");
const mcpRouters: Record<string, AnyTRPCRouter> = {};
for (const selection of mcpWidgetRouterSelections) {
mcpRouters[selection.namespace] = loadedRouters[selection.name];
}
return mcpRouters;
}
@@ -57,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
@@ -75,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 -7
View File
@@ -15,9 +15,8 @@ import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-ur
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import {
normalizeSavedTimetableBaseUrl,
getSavedTimetableBaseUrl,
normalizeTimetableBaseUrlOrThrowBadRequest,
parseSavedTimetableOptions,
resolvePublicTimetableAddressesAsync,
} from "../timetable-options-validation";
@@ -27,11 +26,6 @@ const timetableSourceSchema = z.object({
itemId: z.string().max(100).optional(),
boardId: z.string().max(100).optional(),
});
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
const options = parseSavedTimetableOptions(serializedOptions);
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
interface ResolvedTimetableSource {
baseUrl: string;
pinnedAddresses?: TimetableResolvedAddress[];