diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 0d7f1b880..d351c9238 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -11,13 +11,9 @@ import { useModalAction } from "@homarr/modals"; import { showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import type { BoardItemAdvancedOptions } from "@homarr/validation"; -import { - loadWidgetDynamic, - reduceWidgetOptionsWithDefaultValues, - WidgetEditModal, - widgetImports, -} from "@homarr/widgets"; +import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; +import { WidgetEditModal } from "@homarr/widgets/modals"; import type { Dimensions } from "./_dimension-modal"; import { PreviewDimensionsModal } from "./_dimension-modal"; diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 4c4d9642f..d16ec2c7a 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -5,7 +5,8 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f import { clientApi } from "@homarr/api/client"; import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { WidgetEditModal, widgetImports } from "@homarr/widgets"; +import { widgetImports } from "@homarr/widgets"; +import { WidgetEditModal } from "@homarr/widgets/modals"; import type { Item } from "~/app/[locale]/boards/_types"; import { useEditMode } from "~/app/[locale]/boards/(content)/_context"; diff --git a/apps/nextjs/src/middleware.ts b/apps/nextjs/src/middleware.ts index 491d5b40a..f20def82b 100644 --- a/apps/nextjs/src/middleware.ts +++ b/apps/nextjs/src/middleware.ts @@ -3,6 +3,7 @@ import { createTRPCClient, httpLink } from "@trpc/client"; import SuperJSON from "superjson"; import type { AppRouter } from "@homarr/api"; +import { createHeadersCallbackForSource } from "@homarr/api/client"; import { createI18nMiddleware } from "@homarr/translation/middleware"; export async function middleware(request: NextRequest) { @@ -25,11 +26,7 @@ export const serverFetchApi = createTRPCClient({ httpLink({ url: `http://${process.env.HOSTNAME ?? "localhost"}:3000/api/trpc`, transformer: SuperJSON, - headers() { - const headers = new Headers(); - headers.set("x-trpc-source", "server-fetch"); - return headers; - }, + headers: createHeadersCallbackForSource("server-fetch"), }), ], }); diff --git a/apps/tasks/package.json b/apps/tasks/package.json index 1a33555d8..1871bf767 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -10,7 +10,7 @@ "main": "./src/main.ts", "types": "./src/main.ts", "scripts": { - "build": "esbuild src/main.ts --bundle --platform=node --outfile=tasks.cjs", + "build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:@opentelemetry/api --outfile=tasks.cjs", "clean": "rm -rf .turbo node_modules", "dev": "pnpm with-env tsx ./src/main.ts", "format": "prettier --check . --ignore-path ../../.gitignore", diff --git a/apps/websocket/package.json b/apps/websocket/package.json index d1113bc21..b0b192993 100644 --- a/apps/websocket/package.json +++ b/apps/websocket/package.json @@ -7,7 +7,7 @@ "main": "./src/main.ts", "types": "./src/main.ts", "scripts": { - "build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text", + "build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text", "clean": "rm -rf .turbo node_modules", "dev": "pnpm with-env tsx ./src/main.ts", "format": "prettier --check . --ignore-path ../../.gitignore", diff --git a/package.json b/package.json index aa6d89119..338be184b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache", "lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache", "lint:ws": "pnpm dlx sherif@latest", + "package:new": "turbo gen init", "test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ", "test:e2e": "cross-env NODE_ENV=development vitest e2e", "test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled", diff --git a/packages/api/package.json b/packages/api/package.json index 9bbb3cb1b..04b35ec93 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -33,6 +33,7 @@ "@homarr/old-schema": "workspace:^0.1.0", "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", + "@homarr/request-handler": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@trpc/client": "next", diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts index 78b3326c9..62343ac87 100644 --- a/packages/api/src/middlewares/integration.ts +++ b/packages/api/src/middlewares/integration.ts @@ -150,83 +150,6 @@ export const createManyIntegrationMiddleware = ( }); }; -/** - * Creates a middleware that provides the integrations and their items 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 - * @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.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "piHole", "homeAssistant")).query(...) - * @throws TRPCError NOT_FOUND if the integration for the item was not found - * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations - */ -export const createManyIntegrationOfOneItemMiddleware = ( - action: IntegrationAction, - ...kinds: AtLeastOneOf // Ensure at least one kind is provided -) => { - return publicProcedure - .input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() })) - .use(async ({ ctx, input, next }) => { - const dbIntegrations = await ctx.db.query.integrations.findMany({ - where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)), - with: { - secrets: true, - items: { - with: { - item: { - with: { - section: { - columns: { - boardId: 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(",")}`, - }); - } - - await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session); - - const dbIntegrationWithItem = dbIntegrations.filter((integration) => - integration.items.some((item) => item.itemId === input.itemId), - ); - - if (dbIntegrationWithItem.length === 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Integrations for item were not found", - }); - } - - return next({ - ctx: { - integrations: dbIntegrationWithItem.map( - ({ secrets, kind, groupPermissions: _ignore1, userPermissions: _ignore2, ...rest }) => ({ - ...rest, - kind: kind as TKind, - decryptedSecrets: secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - }), - ), - }, - }); - }); -}; - /** * 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 diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 7713db4f7..6628cf81f 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,20 +1,24 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { CalendarEvent } from "@homarr/integrations/types"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { radarrReleaseTypes } from "@homarr/integrations/types"; +import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; +import { z } from "@homarr/validation"; -import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration"; +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const calendarRouter = createTRPCRouter({ findAllEvents: publicProcedure - .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) - .query(async ({ ctx }) => { - const result = await Promise.all( - ctx.integrations.flatMap(async (integration) => { - const cache = createItemAndIntegrationChannel("calendar", integration.id); - return await cache.getAsync(); + .input(z.object({ year: z.number(), month: z.number(), releaseType: z.array(z.enum(radarrReleaseTypes)) })) + .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) + .query(async ({ ctx, input }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = calendarMonthRequestHandler.handler(integration, input); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return data; }), ); - return result.filter((item) => item !== null).flatMap((item) => item.data); + return results.flat(); }), }); diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 0257c1d7c..a9b76be4f 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -2,31 +2,33 @@ import { observable } from "@trpc/server/observable"; import type { Modify } from "@homarr/common/types"; import type { Integration } from "@homarr/db/schema/sqlite"; -import type { IntegrationKindByCategory, WidgetKind } from "@homarr/definitions"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { integrationCreator } from "@homarr/integrations"; import type { DnsHoleSummary } from "@homarr/integrations/types"; import { controlsInputSchema } from "@homarr/integrations/types"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; -import { z } from "@homarr/validation"; +import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const dnsHoleRouter = createTRPCRouter({ summary: publicProcedure - .input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) })) .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) - .query(async ({ input: { widgetKind }, ctx }) => { + .query(async ({ ctx }) => { const results = await Promise.all( - ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => { - const channel = createItemAndIntegrationChannel(widgetKind, integration.id); - const { data: summary, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) }; + ctx.integrations.map(async (integration) => { + const innerHandler = dnsHoleRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return { - integration, - timestamp, - summary, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data, }; }), ); @@ -34,22 +36,19 @@ export const dnsHoleRouter = createTRPCRouter({ }), subscribeToSummary: publicProcedure - .input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) })) .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) - .subscription(({ input: { widgetKind }, ctx }) => { + .subscription(({ ctx }) => { return observable<{ integration: Modify }>; - timestamp: Date; summary: DnsHoleSummary; }>((emit) => { const unsubscribes: (() => void)[] = []; for (const integrationWithSecrets of ctx.integrations) { const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const channel = createItemAndIntegrationChannel(widgetKind as WidgetKind, integration.id); - const unsubscribe = channel.subscribe((summary) => { + const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((summary) => { emit.next({ integration, - timestamp: new Date(), summary, }); }); @@ -68,6 +67,12 @@ export const dnsHoleRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration } }) => { const client = integrationCreator(integration); await client.enableAsync(); + + const innerHandler = dnsHoleRequestHandler.handler(integration, {}); + // We need to wait for the integration to be enabled before invalidating the cache + await new Promise((resolve) => { + setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000); + }); }), disable: publicProcedure @@ -76,5 +81,11 @@ export const dnsHoleRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration }, input }) => { const client = integrationCreator(integration); await client.disableAsync(input.duration); + + const innerHandler = dnsHoleRequestHandler.handler(integration, {}); + // We need to wait for the integration to be disabled before invalidating the cache + await new Promise((resolve) => { + setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000); + }); }), }); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index 5627a7b4e..e9cdb380e 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -6,7 +6,7 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; import { z } from "@homarr/validation"; import type { IntegrationAction } from "../../middlewares/integration"; @@ -21,12 +21,18 @@ export const downloadsRouter = createTRPCRouter({ .unstable_concat(createDownloadClientIntegrationMiddleware("query")) .query(async ({ ctx }) => { return await Promise.all( - ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => { - const channel = createItemAndIntegrationChannel("downloads", integration.id); - const { data, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) }; + ctx.integrations.map(async (integration) => { + const innerHandler = downloadClientRequestHandler.handler(integration, {}); + + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return { - integration, - timestamp, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, data, }; }), @@ -37,17 +43,15 @@ export const downloadsRouter = createTRPCRouter({ .subscription(({ ctx }) => { return observable<{ integration: Modify }>; - timestamp: Date; data: DownloadClientJobsAndStatus; }>((emit) => { const unsubscribes: (() => void)[] = []; for (const integrationWithSecrets of ctx.integrations) { const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const channel = createItemAndIntegrationChannel("downloads", integration.id); - const unsubscribe = channel.subscribe((data) => { + const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((data) => { emit.next({ integration, - timestamp: new Date(), data, }); }); diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 28e0b9b6a..8c7ee16dc 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,7 +1,7 @@ import { observable } from "@trpc/server/observable"; import type { HealthMonitoring } from "@homarr/integrations"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -12,14 +12,14 @@ export const healthMonitoringRouter = createTRPCRouter({ .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.map(async (integration) => { - const channel = createItemAndIntegrationChannel("healthMonitoring", integration.id); - const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) }; + const innerHandler = systemInfoRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return { integrationId: integration.id, integrationName: integration.name, - healthInfo, - timestamp, + healthInfo: data, + updatedAt: timestamp, }; }), ); @@ -31,8 +31,8 @@ export const healthMonitoringRouter = createTRPCRouter({ return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => { const unsubscribes: (() => void)[] = []; for (const integration of ctx.integrations) { - const channel = createItemAndIntegrationChannel("healthMonitoring", integration.id); - const unsubscribe = channel.subscribe((healthInfo) => { + const innerHandler = systemInfoRequestHandler.handler(integration, {}); + const unsubscribe = innerHandler.subscribe((healthInfo) => { emit.next({ integrationId: integration.id, healthInfo, diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 731216f0c..19dedb595 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { integrationCreator } from "@homarr/integrations"; import type { Indexer } from "@homarr/integrations/types"; import { logger } from "@homarr/log"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager"; import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; @@ -20,14 +20,8 @@ export const indexerManagerRouter = createTRPCRouter({ .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { - const client = integrationCreator(integration); - const indexers = await client.getIndexersAsync().catch((err) => { - logger.error("indexer-manager router - ", err); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to fetch indexers for ${integration.name} (${integration.id})`, - }); - }); + const innerHandler = indexerManagerRequestHandler.handler(integration, {}); + const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return { integrationId: integration.id, @@ -43,11 +37,11 @@ export const indexerManagerRouter = createTRPCRouter({ .subscription(({ ctx }) => { return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => { const unsubscribes: (() => void)[] = []; - for (const integration of ctx.integrations) { - const channel = createItemAndIntegrationChannel("indexerManager", integration.id); - const unsubscribe = channel.subscribe((indexers) => { + for (const integrationWithSecrets of ctx.integrations) { + const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((indexers) => { emit.next({ - integrationId: integration.id, + integrationId: integrationWithSecrets.id, indexers, }); }); @@ -60,7 +54,6 @@ export const indexerManagerRouter = createTRPCRouter({ }; }); }), - testAllIndexers: publicProcedure .unstable_concat(createIndexerManagerIntegrationMiddleware("interact")) .mutation(async ({ ctx }) => { diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 2c2916715..c1a237d03 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -1,53 +1,110 @@ +import { observable } from "@trpc/server/observable"; + import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations"; -import { integrationCreator } from "@homarr/integrations"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { integrationCreator, MediaRequestStatus } from "@homarr/integrations"; +import type { MediaRequest } from "@homarr/integrations/types"; +import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; +import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { z } from "@homarr/validation"; -import { - createManyIntegrationOfOneItemMiddleware, - createOneIntegrationMiddleware, -} from "../../middlewares/integration"; +import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; export const mediaRequestsRouter = createTRPCRouter({ getLatestRequests: publicProcedure - .unstable_concat( - createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), - ) - .query(async ({ input }) => { - return await Promise.all( - input.integrationIds.map(async (integrationId) => { - const channel = createItemAndIntegrationChannel("mediaRequests-requestList", integrationId); - return await channel.getAsync(); + .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = mediaRequestListRequestHandler.handler(integration, {}); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + data, + }; }), ); + return results + .flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id }))) + .sort(({ status: statusA }, { status: statusB }) => { + if (statusA === MediaRequestStatus.PendingApproval) { + return -1; + } + if (statusB === MediaRequestStatus.PendingApproval) { + return 1; + } + return 0; + }); + }), + subscribeToLatestRequests: publicProcedure + .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) + .subscription(({ ctx }) => { + return observable<{ + integrationId: string; + requests: MediaRequest[]; + }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const innerHandler = mediaRequestListRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((requests) => { + emit.next({ + integrationId: integration.id, + requests, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); }), getStats: publicProcedure - .unstable_concat( - createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), - ) - .query(async ({ input }) => { - return await Promise.all( - input.integrationIds.map(async (integrationId) => { - const channel = createItemAndIntegrationChannel( - "mediaRequests-requestStats", - integrationId, - ); - return await channel.getAsync(); + .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {}); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + data, + }; }), ); + return { + stats: results.flatMap((result) => result.data.stats), + users: results + .map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration }))) + .flat() + .sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA), + integrations: results.map((result) => result.integration), + }; }), answerRequest: protectedProcedure .unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest"))) .input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) })) .mutation(async ({ ctx: { integration }, input }) => { const integrationInstance = integrationCreator(integration); + const innerHandler = mediaRequestListRequestHandler.handler(integration, {}); if (input.answer === "approve") { await integrationInstance.approveRequestAsync(input.requestId); + await innerHandler.invalidateAsync(); return; } await integrationInstance.declineRequestAsync(input.requestId); + await innerHandler.invalidateAsync(); }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index ec93ad206..c567c21a2 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"; import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; @@ -17,11 +17,11 @@ export const mediaServerRouter = createTRPCRouter({ .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.map(async (integration) => { - const channel = createItemAndIntegrationChannel("mediaServer", integration.id); - const data = await channel.getAsync(); + const innerHandler = mediaServerRequestHandler.handler(integration, {}); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return { integrationId: integration.id, - sessions: data?.data ?? [], + sessions: data, }; }), ); @@ -32,8 +32,9 @@ export const mediaServerRouter = createTRPCRouter({ return observable<{ integrationId: string; data: StreamSession[] }>((emit) => { const unsubscribes: (() => void)[] = []; for (const integration of ctx.integrations) { - const channel = createItemAndIntegrationChannel("mediaServer", integration.id); - const unsubscribe = channel.subscribe((sessions) => { + const innerHandler = mediaServerRequestHandler.handler(integration, {}); + + const unsubscribe = innerHandler.subscribe((sessions) => { emit.next({ integrationId: integration.id, data: sessions, diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index 3cfd3a98a..1b8b6ec64 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { integrationCreator } from "@homarr/integrations"; -import { homeAssistantEntityState } from "@homarr/redis"; +import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state"; import { z } from "@homarr/validation"; import type { IntegrationAction } from "../../middlewares/integration"; @@ -13,29 +13,45 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) => createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer")); export const smartHomeRouter = createTRPCRouter({ - subscribeEntityState: publicProcedure.input(z.object({ entityId: z.string() })).subscription(({ input }) => { - return observable<{ - entityId: string; - state: string; - }>((emit) => { - const unsubscribe = homeAssistantEntityState.subscribe((message) => { - if (message.entityId !== input.entityId) { - return; - } - emit.next(message); - }); + entityState: publicProcedure + .input(z.object({ entityId: z.string() })) + .unstable_concat(createSmartHomeIntegrationMiddleware("query")) + .query(async ({ ctx: { integration }, input }) => { + const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + }), + subscribeEntityState: publicProcedure + .unstable_concat(createSmartHomeIntegrationMiddleware("query")) + .input(z.object({ entityId: z.string() })) + .subscription(({ input, ctx }) => { + return observable<{ + entityId: string; + state: string; + }>((emit) => { + const innerHandler = smartHomeEntityStateRequestHandler.handler(ctx.integration, { + entityId: input.entityId, + }); + const unsubscribe = innerHandler.subscribe((state) => { + emit.next({ state, entityId: input.entityId }); + }); - return () => { - unsubscribe(); - }; - }); - }), + return () => { + unsubscribe(); + }; + }); + }), switchEntity: publicProcedure .unstable_concat(createSmartHomeIntegrationMiddleware("interact")) .input(z.object({ entityId: z.string() })) .mutation(async ({ ctx: { integration }, input }) => { const client = integrationCreator(integration); - return await client.triggerToggleAsync(input.entityId); + const success = await client.triggerToggleAsync(input.entityId); + + const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); + await innerHandler.invalidateAsync(); + + return success; }), executeAutomation: publicProcedure .unstable_concat(createSmartHomeIntegrationMiddleware("interact")) diff --git a/packages/common/src/hooks.ts b/packages/common/src/hooks.ts index 6185b27e2..e5040968e 100644 --- a/packages/common/src/hooks.ts +++ b/packages/common/src/hooks.ts @@ -21,3 +21,20 @@ export const useTimeAgo = (timestamp: Date) => { return timeAgo; }; + +export const useIntegrationConnected = (updatedAt: Date, { timeout = 30000 }) => { + const [connected, setConnected] = useState(Math.abs(dayjs(updatedAt).diff()) < timeout); + + useEffect(() => { + setConnected(Math.abs(dayjs(updatedAt).diff()) < timeout); + + const delayUntilTimeout = timeout - Math.abs(dayjs(updatedAt).diff()); + const timeoutRef = setTimeout(() => { + setConnected(false); + }, delayUntilTimeout); + + return () => clearTimeout(timeoutRef); + }, [updatedAt, timeout]); + + return connected; +}; diff --git a/packages/common/src/object.ts b/packages/common/src/object.ts index 7e0ccde7f..96ec46dbb 100644 --- a/packages/common/src/object.ts +++ b/packages/common/src/object.ts @@ -1,3 +1,5 @@ +import { hashKey } from "@tanstack/query-core"; + export function objectKeys(obj: O): (keyof O)[] { return Object.keys(obj) as (keyof O)[]; } @@ -7,3 +9,7 @@ type Entries = { }[keyof T][]; export const objectEntries = (obj: T) => Object.entries(obj) as Entries; + +export const hashObjectBase64 = (obj: object) => { + return Buffer.from(hashKey([obj])).toString("base64"); +}; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 45c9ad902..be56092f5 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -9,3 +9,5 @@ export type Modify>> = { export type RemoveReadonly = { -readonly [P in keyof T]: T[P] extends Record ? RemoveReadonly : T[P]; }; + +export type MaybeArray = T | T[]; diff --git a/packages/cron-jobs/package.json b/packages/cron-jobs/package.json index 12ed55a0d..a23e7cc0e 100644 --- a/packages/cron-jobs/package.json +++ b/packages/cron-jobs/package.json @@ -35,6 +35,7 @@ "@homarr/log": "workspace:^0.1.0", "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", + "@homarr/request-handler": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0" diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 8a358988f..cf4132911 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -6,7 +6,7 @@ import { healthMonitoringJob } from "./jobs/integrations/health-monitoring"; import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; import { indexerManagerJob } from "./jobs/integrations/indexer-manager"; import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; -import { mediaRequestsJob } from "./jobs/integrations/media-requests"; +import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests"; import { mediaServerJob } from "./jobs/integrations/media-server"; import { pingJob } from "./jobs/ping"; import type { RssFeed } from "./jobs/rss-feeds"; @@ -23,7 +23,8 @@ export const jobGroup = createCronJobGroup({ mediaOrganizer: mediaOrganizerJob, downloads: downloadsJob, dnsHole: dnsHoleJob, - mediaRequests: mediaRequestsJob, + mediaRequestStats: mediaRequestStatsJob, + mediaRequestList: mediaRequestListJob, rssFeeds: rssFeedsJob, indexerManager: indexerManagerJob, healthMonitoring: healthMonitoringJob, diff --git a/packages/cron-jobs/src/jobs/integrations/dns-hole.ts b/packages/cron-jobs/src/jobs/integrations/dns-hole.ts index aaeaf4bee..45e83d814 100644 --- a/packages/cron-jobs/src/jobs/integrations/dns-hole.ts +++ b/packages/cron-jobs/src/jobs/integrations/dns-hole.ts @@ -1,28 +1,15 @@ import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; -import { integrationCreatorFromSecrets } from "@homarr/integrations"; -import type { DnsHoleSummary } from "@homarr/integrations/types"; -import { logger } from "@homarr/log"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; import { createCronJob } from "../../lib"; -export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(async () => { - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: ["dnsHoleSummary", "dnsHoleControls"], - }); - - for (const itemForIntegration of itemsForIntegration) { - for (const { integration } of itemForIntegration.integrations) { - const integrationInstance = integrationCreatorFromSecrets(integration); - await integrationInstance - .getSummaryAsync() - .then(async (data) => { - const channel = createItemAndIntegrationChannel(itemForIntegration.kind, integration.id); - await channel.publishAndUpdateLastStateAsync(data); - }) - .catch((error) => logger.error(`Could not retrieve data for ${integration.name}: "${error}"`)); - } - } -}); +export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback( + createRequestIntegrationJobHandler(dnsHoleRequestHandler.handler, { + widgetKinds: ["dnsHoleSummary", "dnsHoleControls"], + getInput: { + dnsHoleSummary: () => ({}), + dnsHoleControls: () => ({}), + }, + }), +); diff --git a/packages/cron-jobs/src/jobs/integrations/downloads.ts b/packages/cron-jobs/src/jobs/integrations/downloads.ts index 35ff36277..e7d010d65 100644 --- a/packages/cron-jobs/src/jobs/integrations/downloads.ts +++ b/packages/cron-jobs/src/jobs/integrations/downloads.ts @@ -1,27 +1,14 @@ import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; -import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; -import { integrationCreatorFromSecrets } from "@homarr/integrations"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; import { createCronJob } from "../../lib"; -export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(async () => { - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: ["downloads"], - }); - - for (const itemForIntegration of itemsForIntegration) { - for (const { integration } of itemForIntegration.integrations) { - const integrationInstance = integrationCreatorFromSecrets(integration); - await integrationInstance - .getClientJobsAndStatusAsync() - .then(async (data) => { - const channel = createItemAndIntegrationChannel("downloads", integration.id); - await channel.publishAndUpdateLastStateAsync(data); - }) - .catch((error) => console.error(`Could not retrieve data for ${integration.name}: "${error}"`)); - } - } -}); +export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback( + createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, { + widgetKinds: ["downloads"], + getInput: { + downloads: () => ({}), + }, + }), +); diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts index 1f187d354..50c80ce84 100644 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -1,22 +1,14 @@ import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; -import { integrationCreatorFromSecrets } from "@homarr/integrations"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; import { createCronJob } from "../../lib"; -export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(async () => { - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: ["healthMonitoring"], - }); - - for (const itemForIntegration of itemsForIntegration) { - for (const integration of itemForIntegration.integrations) { - const openmediavault = integrationCreatorFromSecrets(integration.integration); - const healthInfo = await openmediavault.getSystemInfoAsync(); - const channel = createItemAndIntegrationChannel("healthMonitoring", integration.integrationId); - await channel.publishAndUpdateLastStateAsync(healthInfo); - } - } -}); +export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback( + createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, { + widgetKinds: ["healthMonitoring"], + getInput: { + healthMonitoring: () => ({}), + }, + }), +); diff --git a/packages/cron-jobs/src/jobs/integrations/home-assistant.ts b/packages/cron-jobs/src/jobs/integrations/home-assistant.ts index 782bb5bbc..2723391a7 100644 --- a/packages/cron-jobs/src/jobs/integrations/home-assistant.ts +++ b/packages/cron-jobs/src/jobs/integrations/home-assistant.ts @@ -1,42 +1,16 @@ -import SuperJSON from "superjson"; - import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; -import { integrationCreatorFromSecrets } from "@homarr/integrations"; -import { logger } from "@homarr/log"; -import { homeAssistantEntityState } from "@homarr/redis"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state"; -// This import is done that way to avoid circular dependencies. -import type { WidgetComponentProps } from "../../../../widgets"; import { createCronJob } from "../../lib"; -export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(async () => { - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: ["smartHome-entityState"], - }); - - for (const itemForIntegration of itemsForIntegration) { - const integration = itemForIntegration.integrations[0]?.integration; - if (!integration) { - continue; - } - - const options = SuperJSON.parse["options"]>( - itemForIntegration.options, - ); - - const homeAssistant = integrationCreatorFromSecrets(integration); - const state = await homeAssistant.getEntityStateAsync(options.entityId); - - if (!state.success) { - logger.error("Unable to fetch data from Home Assistant"); - continue; - } - - await homeAssistantEntityState.publishAsync({ - entityId: options.entityId, - state: state.data.state, - }); - } -}); +export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback( + createRequestIntegrationJobHandler(smartHomeEntityStateRequestHandler.handler, { + widgetKinds: ["smartHome-entityState"], + getInput: { + "smartHome-entityState": (options) => ({ + entityId: options.entityId, + }), + }, + }), +); diff --git a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts b/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts index 858e0c84a..11da75508 100644 --- a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts +++ b/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts @@ -1,19 +1,14 @@ -import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; -import { integrationCreatorFromSecrets } from "@homarr/integrations"; +import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; +import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; import { createCronJob } from "../../lib"; -export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => { - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: ["indexerManager"], - }); - - for (const itemForIntegration of itemsForIntegration) { - for (const { integration } of itemForIntegration.integrations) { - const integrationInstance = integrationCreatorFromSecrets(integration); - await integrationInstance.getIndexersAsync(); - } - } -}); +export const indexerManagerJob = createCronJob("indexerManager", EVERY_5_MINUTES).withCallback( + createRequestIntegrationJobHandler(indexerManagerRequestHandler.handler, { + widgetKinds: ["indexerManager"], + getInput: { + indexerManager: () => ({}), + }, + }), +); diff --git a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts index ede54eaf7..1d7128e4d 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts @@ -1,36 +1,35 @@ import dayjs from "dayjs"; -import SuperJSON from "superjson"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; -import { integrationCreatorFromSecrets } from "@homarr/integrations"; -import type { CalendarEvent } from "@homarr/integrations/types"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -// This import is done that way to avoid circular dependencies. -import type { WidgetComponentProps } from "../../../../widgets"; import { createCronJob } from "../../lib"; -export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => { - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: ["calendar"], - }); +export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback( + createRequestIntegrationJobHandler(calendarMonthRequestHandler.handler, { + widgetKinds: ["calendar"], + getInput: { + // Request handler will run for all specified months + calendar: (options) => { + const inputs = []; - for (const itemForIntegration of itemsForIntegration) { - for (const { integration } of itemForIntegration.integrations) { - const options = SuperJSON.parse["options"]>(itemForIntegration.options); + const startOffset = -Number(options.filterPastMonths); + const endOffset = Number(options.filterFutureMonths); - const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate(); - const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate(); + for (let offsetMonths = startOffset; offsetMonths <= endOffset; offsetMonths++) { + const year = dayjs().subtract(offsetMonths, "months").year(); + const month = dayjs().subtract(offsetMonths, "months").month(); - //Asserting the integration kind until all of them get implemented - const integrationInstance = integrationCreatorFromSecrets(integration); + inputs.push({ + year, + month, + releaseType: options.releaseType, + }); + } - const events = await integrationInstance.getCalendarEventsAsync(start, end); - - const cache = createItemAndIntegrationChannel("calendar", integration.id); - await cache.setAsync(events); - } - } -}); + return inputs; + }, + }, + }), +); diff --git a/packages/cron-jobs/src/jobs/integrations/media-requests.ts b/packages/cron-jobs/src/jobs/integrations/media-requests.ts index 8e34d7305..ededb6141 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-requests.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-requests.ts @@ -1,42 +1,24 @@ -import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; -import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations"; -import { integrationCreatorFromSecrets } from "@homarr/integrations"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; +import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createCronJob } from "../../lib"; -export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).withCallback(async () => { - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: ["mediaRequests-requestList", "mediaRequests-requestStats"], - }); +export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MINUTE).withCallback( + createRequestIntegrationJobHandler(mediaRequestStatsRequestHandler.handler, { + widgetKinds: ["mediaRequests-requestStats"], + getInput: { + "mediaRequests-requestStats": () => ({}), + }, + }), +); - for (const itemForIntegration of itemsForIntegration) { - for (const { integration } of itemForIntegration.integrations) { - const requestsIntegration = integrationCreatorFromSecrets(integration); - - const mediaRequests = await requestsIntegration.getRequestsAsync(); - const requestsStats = await requestsIntegration.getStatsAsync(); - const requestsUsers = await requestsIntegration.getUsersAsync(); - const requestListChannel = createItemAndIntegrationChannel( - "mediaRequests-requestList", - integration.id, - ); - await requestListChannel.publishAndUpdateLastStateAsync({ - integration: { id: integration.id }, - medias: mediaRequests, - }); - - const requestStatsChannel = createItemAndIntegrationChannel( - "mediaRequests-requestStats", - integration.id, - ); - await requestStatsChannel.publishAndUpdateLastStateAsync({ - integration: { kind: integration.kind, name: integration.name }, - stats: requestsStats, - users: requestsUsers, - }); - } - } -}); +export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback( + createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, { + widgetKinds: ["mediaRequests-requestList"], + getInput: { + "mediaRequests-requestList": () => ({}), + }, + }), +); diff --git a/packages/cron-jobs/src/jobs/integrations/media-server.ts b/packages/cron-jobs/src/jobs/integrations/media-server.ts index 88c7e85ba..3ba0324cb 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-server.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-server.ts @@ -1,22 +1,14 @@ import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; -import { integrationCreatorFromSecrets } from "@homarr/integrations"; -import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"; import { createCronJob } from "../../lib"; -export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(async () => { - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: ["mediaServer"], - }); - - for (const itemForIntegration of itemsForIntegration) { - for (const { integration } of itemForIntegration.integrations) { - const integrationInstance = integrationCreatorFromSecrets(integration); - const streamSessions = await integrationInstance.getCurrentSessionsAsync(); - const channel = createItemAndIntegrationChannel("mediaServer", integration.id); - await channel.publishAndUpdateLastStateAsync(streamSessions); - } - } -}); +export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback( + createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, { + widgetKinds: ["mediaServer"], + getInput: { + mediaServer: () => ({}), + }, + }), +); diff --git a/packages/integrations/src/calendar-types.ts b/packages/integrations/src/calendar-types.ts index 95399c4b1..0aad96b03 100644 --- a/packages/integrations/src/calendar-types.ts +++ b/packages/integrations/src/calendar-types.ts @@ -1,11 +1,11 @@ export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const; -type ReleaseType = (typeof radarrReleaseTypes)[number]; +export type RadarrReleaseType = (typeof radarrReleaseTypes)[number]; export interface CalendarEvent { name: string; subName: string; date: Date; - dates?: { type: ReleaseType; date: Date }[]; + dates?: { type: RadarrReleaseType; date: Date }[]; description?: string; thumbnail?: string; mediaInformation?: { diff --git a/packages/integrations/src/interfaces/downloads/download-client-items.ts b/packages/integrations/src/interfaces/downloads/download-client-items.ts index aaca5b40a..53d31b49a 100644 --- a/packages/integrations/src/interfaces/downloads/download-client-items.ts +++ b/packages/integrations/src/interfaces/downloads/download-client-items.ts @@ -45,7 +45,7 @@ export const downloadClientItemSchema = z.object({ export type DownloadClientItem = z.infer; export type ExtendedDownloadClientItem = { - integration: Integration; + integration: Pick; received: number; ratio?: number; actions?: { diff --git a/packages/integrations/src/interfaces/downloads/download-client-status.ts b/packages/integrations/src/interfaces/downloads/download-client-status.ts index 7c55e77c5..d8bfc09c0 100644 --- a/packages/integrations/src/interfaces/downloads/download-client-status.ts +++ b/packages/integrations/src/interfaces/downloads/download-client-status.ts @@ -11,7 +11,7 @@ export interface DownloadClientStatus { type: "usenet" | "torrent"; } export interface ExtendedClientStatus { - integration: Integration; + integration: Pick & { updatedAt: Date }; interact: boolean; status?: { /** To derive from current items */ diff --git a/packages/integrations/src/interfaces/media-requests/media-request.ts b/packages/integrations/src/interfaces/media-requests/media-request.ts index 51c97c24c..c997e8014 100644 --- a/packages/integrations/src/interfaces/media-requests/media-request.ts +++ b/packages/integrations/src/interfaces/media-requests/media-request.ts @@ -39,10 +39,6 @@ export interface RequestUser { } export interface MediaRequestStats { - integration: { - kind: string; - name: string; - }; stats: RequestStats; users: RequestUser[]; } diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 8b1d9e8e6..db5ab2b56 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -1,3 +1,4 @@ +import { logger } from "@homarr/log"; import { z } from "@homarr/validation"; import { Integration } from "../base/integration"; @@ -125,20 +126,38 @@ export class OverseerrIntegration extends Integration implements ISearchableInte } public async approveRequestAsync(requestId: number): Promise { + logger.info(`Approving media request id='${requestId}' integration='${this.integration.name}'`); await fetch(`${this.integration.url}/api/v1/request/${requestId}/approve`, { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, + }).then((response) => { + if (!response.ok) { + logger.error( + `Failed to approve media request id='${requestId}' integration='${this.integration.name}' reason='${response.status} ${response.statusText}' url='${response.url}'`, + ); + } + + logger.info(`Successfully approved media request id='${requestId}' integration='${this.integration.name}'`); }); } public async declineRequestAsync(requestId: number): Promise { + logger.info(`Declining media request id='${requestId}' integration='${this.integration.name}'`); await fetch(`${this.integration.url}/api/v1/request/${requestId}/decline`, { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, + }).then((response) => { + if (!response.ok) { + logger.error( + `Failed to decline media request id='${requestId}' integration='${this.integration.name}' reason='${response.status} ${response.statusText}' url='${response.url}'`, + ); + } + + logger.info(`Successfully declined media request id='${requestId}' integration='${this.integration.name}'`); }); } diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 1d3c961d5..2c1f72c39 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -5,3 +5,4 @@ export * from "./interfaces/indexer-manager/indexer"; export * from "./interfaces/media-requests/media-request"; export * from "./pi-hole/pi-hole-types"; export * from "./base/searchable-integration"; +export * from "./homeassistant/homeassistant-types"; diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 67373769e..d30adfccd 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -1,6 +1,13 @@ import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel"; -export { createCacheChannel, createItemAndIntegrationChannel, createItemChannel, handshakeAsync } from "./lib/channel"; +export { + createCacheChannel, + createItemAndIntegrationChannel, + createItemChannel, + createIntegrationOptionsChannel, + createChannelWithLatestAndEvents, + handshakeAsync, +} from "./lib/channel"; export const exampleChannel = createSubPubChannel<{ message: string }>("example"); export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>( diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 698bafb7c..cdf84f492 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -1,5 +1,6 @@ import superjson from "superjson"; +import { hashObjectBase64 } from "@homarr/common"; import { createId } from "@homarr/db"; import type { WidgetKind } from "@homarr/definitions"; import { logger } from "@homarr/log"; @@ -172,11 +173,21 @@ export const createItemAndIntegrationChannel = (kind: WidgetKind, integra return createChannelWithLatestAndEvents(channelName); }; +export const createIntegrationOptionsChannel = ( + integrationId: string, + queryKey: string, + options: Record, +) => { + const optionsKey = hashObjectBase64(options); + const channelName = `integration:${integrationId}:${queryKey}:options:${optionsKey}`; + return createChannelWithLatestAndEvents(channelName); +}; + export const createItemChannel = (itemId: string) => { return createChannelWithLatestAndEvents(`item:${itemId}`); }; -const createChannelWithLatestAndEvents = (channelName: string) => { +export const createChannelWithLatestAndEvents = (channelName: string) => { return { subscribe: (callback: (data: TData) => void) => { return ChannelSubscriptionTracker.subscribe(channelName, (message) => { @@ -196,6 +207,7 @@ const createChannelWithLatestAndEvents = (channelName: string) => { return superjson.parse<{ data: TData; timestamp: Date }>(data); }, + name: channelName, }; }; diff --git a/packages/request-handler/eslint.config.js b/packages/request-handler/eslint.config.js new file mode 100644 index 000000000..5b19b6f8a --- /dev/null +++ b/packages/request-handler/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@homarr/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, +]; diff --git a/packages/request-handler/package.json b/packages/request-handler/package.json new file mode 100644 index 000000000..13d3abe0b --- /dev/null +++ b/packages/request-handler/package.json @@ -0,0 +1,41 @@ +{ + "name": "@homarr/request-handler", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "eslint", + "format": "prettier --check . --ignore-path ../../.gitignore", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@homarr/common": "workspace:^0.1.0", + "@homarr/db": "workspace:^0.1.0", + "@homarr/definitions": "workspace:^0.1.0", + "@homarr/integrations": "workspace:^0.1.0", + "@homarr/log": "workspace:^0.1.0", + "@homarr/redis": "workspace:^0.1.0", + "dayjs": "^1.11.13", + "superjson": "2.2.1" + }, + "devDependencies": { + "@homarr/eslint-config": "workspace:^0.2.0", + "@homarr/prettier-config": "workspace:^0.1.0", + "@homarr/tsconfig": "workspace:^0.1.0", + "eslint": "^9.15.0", + "typescript": "^5.6.3" + }, + "prettier": "@homarr/prettier-config" +} diff --git a/packages/request-handler/src/calendar.ts b/packages/request-handler/src/calendar.ts new file mode 100644 index 000000000..3987e0497 --- /dev/null +++ b/packages/request-handler/src/calendar.ts @@ -0,0 +1,22 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; +import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler< + CalendarEvent[], + IntegrationKindByCategory<"calendar">, + { year: number; month: number; releaseType: RadarrReleaseType[] } +>({ + async requestAsync(integration, input) { + const integrationInstance = integrationCreator(integration); + const startDate = dayjs().year(input.year).month(input.month).startOf("month"); + const endDate = startDate.clone().endOf("month"); + return await integrationInstance.getCalendarEventsAsync(startDate.toDate(), endDate.toDate()); + }, + cacheDuration: dayjs.duration(1, "minute"), + queryKey: "calendarMonth", +}); diff --git a/packages/request-handler/src/dns-hole.ts b/packages/request-handler/src/dns-hole.ts new file mode 100644 index 000000000..c05ee575d --- /dev/null +++ b/packages/request-handler/src/dns-hole.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; +import type { DnsHoleSummary } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler< + DnsHoleSummary, + IntegrationKindByCategory<"dnsHole">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = integrationCreator(integration); + return await integrationInstance.getSummaryAsync(); + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "dnsHoleSummary", +}); diff --git a/packages/request-handler/src/downloads.ts b/packages/request-handler/src/downloads.ts new file mode 100644 index 000000000..71fc0d4a4 --- /dev/null +++ b/packages/request-handler/src/downloads.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; +import { integrationCreator } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const downloadClientRequestHandler = createCachedIntegrationRequestHandler< + DownloadClientJobsAndStatus, + IntegrationKindByCategory<"downloadClient">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = integrationCreator(integration); + return await integrationInstance.getClientJobsAndStatusAsync(); + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "downloadClientJobStatus", +}); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts new file mode 100644 index 000000000..cd3b364d1 --- /dev/null +++ b/packages/request-handler/src/health-monitoring.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; +import type { HealthMonitoring } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< + HealthMonitoring, + IntegrationKindByCategory<"healthMonitoring">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = integrationCreator(integration); + return await integrationInstance.getSystemInfoAsync(); + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "systemInfo", +}); diff --git a/packages/request-handler/src/indexer-manager.ts b/packages/request-handler/src/indexer-manager.ts new file mode 100644 index 000000000..5262fa8f0 --- /dev/null +++ b/packages/request-handler/src/indexer-manager.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; +import type { Indexer } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const indexerManagerRequestHandler = createCachedIntegrationRequestHandler< + Indexer[], + IntegrationKindByCategory<"indexerManager">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = integrationCreator(integration); + return await integrationInstance.getIndexersAsync(); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "indexerManager", +}); diff --git a/packages/request-handler/src/lib/cached-integration-request-handler.ts b/packages/request-handler/src/lib/cached-integration-request-handler.ts new file mode 100644 index 000000000..437e7117c --- /dev/null +++ b/packages/request-handler/src/lib/cached-integration-request-handler.ts @@ -0,0 +1,42 @@ +import type { Duration } from "dayjs/plugin/duration"; + +import type { Modify } from "@homarr/common/types"; +import type { Integration, IntegrationSecret } from "@homarr/db/schema/sqlite"; +import type { IntegrationKind } from "@homarr/definitions"; +import { createIntegrationOptionsChannel } from "@homarr/redis"; + +import { createCachedRequestHandler } from "./cached-request-handler"; + +type IntegrationOfKind = Omit & { + kind: TKind; + decryptedSecrets: Modify, { value: string }>[]; +}; + +interface Options> { + // Unique key for this request handler + queryKey: string; + requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise; + cacheDuration: Duration; +} + +export const createCachedIntegrationRequestHandler = < + TData, + TKind extends IntegrationKind, + TInput extends Record, +>( + options: Options, +) => { + return { + handler: (integration: IntegrationOfKind, itemOptions: TInput) => + createCachedRequestHandler({ + queryKey: options.queryKey, + requestAsync: async (input: { options: TInput; integration: IntegrationOfKind }) => { + return await options.requestAsync(input.integration, input.options); + }, + cacheDuration: options.cacheDuration, + createRedisChannel(input, options) { + return createIntegrationOptionsChannel(input.integration.id, options.queryKey, input.options); + }, + }).handler({ options: itemOptions, integration }), + }; +}; diff --git a/packages/request-handler/src/lib/cached-request-handler.ts b/packages/request-handler/src/lib/cached-request-handler.ts new file mode 100644 index 000000000..2e1739248 --- /dev/null +++ b/packages/request-handler/src/lib/cached-request-handler.ts @@ -0,0 +1,74 @@ +import dayjs from "dayjs"; +import type { Duration } from "dayjs/plugin/duration"; + +import { logger } from "@homarr/log"; +import type { createChannelWithLatestAndEvents } from "@homarr/redis"; + +interface Options> { + // Unique key for this request handler + queryKey: string; + requestAsync: (input: TInput) => Promise; + createRedisChannel: ( + input: TInput, + options: Options, + ) => ReturnType>; + cacheDuration: Duration; +} + +export const createCachedRequestHandler = >( + options: Options, +) => { + return { + handler: (input: TInput) => { + const channel = options.createRedisChannel(input, options); + + return { + async getCachedOrUpdatedDataAsync({ forceUpdate = false }) { + const requestNewDataAsync = async () => { + const data = await options.requestAsync(input); + await channel.publishAndUpdateLastStateAsync(data); + return { + data, + timestamp: new Date(), + }; + }; + + if (forceUpdate) { + logger.debug( + `Cached request handler forced update for channel='${channel.name}' queryKey='${options.queryKey}'`, + ); + return await requestNewDataAsync(); + } + + const channelData = await channel.getAsync(); + + const shouldRequestNewData = + !channelData || + dayjs().diff(channelData.timestamp, "milliseconds") > options.cacheDuration.asMilliseconds(); + + if (shouldRequestNewData) { + logger.debug( + `Cached request handler cache miss for channel='${channel.name}' queryKey='${options.queryKey}' reason='${!channelData ? "no data" : "cache expired"}'`, + ); + return await requestNewDataAsync(); + } + + logger.debug( + `Cached request handler cache hit for channel='${channel.name}' queryKey='${options.queryKey}' expiresAt='${dayjs(channelData.timestamp).add(options.cacheDuration).toISOString()}'`, + ); + + return channelData; + }, + async invalidateAsync() { + logger.debug( + `Cached request handler invalidating cache channel='${channel.name}' queryKey='${options.queryKey}'`, + ); + await this.getCachedOrUpdatedDataAsync({ forceUpdate: true }); + }, + subscribe(callback: (data: TData) => void) { + return channel.subscribe(callback); + }, + }; + }, + }; +}; diff --git a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts new file mode 100644 index 000000000..85bbd78f8 --- /dev/null +++ b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts @@ -0,0 +1,103 @@ +import SuperJSON from "superjson"; + +import { hashObjectBase64, Stopwatch } from "@homarr/common"; +import { decryptSecret } from "@homarr/common/server"; +import type { MaybeArray } from "@homarr/common/types"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import type { WidgetKind } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +// This imports are done that way to avoid circular dependencies. +import type { inferSupportedIntegrationsStrict } from "../../../widgets/src"; +import { reduceWidgetOptionsWithDefaultValues } from "../../../widgets/src"; +import type { WidgetComponentProps } from "../../../widgets/src/definition"; +import type { createCachedIntegrationRequestHandler } from "./cached-integration-request-handler"; + +export const createRequestIntegrationJobHandler = < + TWidgetKind extends WidgetKind, + TIntegrationKind extends inferSupportedIntegrationsStrict, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + THandler extends ReturnType>["handler"], +>( + handler: THandler, + { + widgetKinds, + getInput, + }: { + widgetKinds: TWidgetKind[]; + getInput: { + [key in TWidgetKind]: (options: WidgetComponentProps["options"]) => MaybeArray[1]>; + }; + }, +) => { + return async () => { + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: widgetKinds, + }); + + logger.debug( + `Found items for integration widgetKinds='${widgetKinds.join(",")}' count=${itemsForIntegration.length}`, + ); + + const distinctIntegrations: { + integrationId: string; + inputHash: string; + integration: (typeof itemsForIntegration)[number]["integrations"][number]["integration"]; + input: Parameters[1]; + }[] = []; + + for (const itemForIntegration of itemsForIntegration) { + const oneOrMultipleInputs = getInput[itemForIntegration.kind]( + reduceWidgetOptionsWithDefaultValues( + itemForIntegration.kind, + SuperJSON.parse(itemForIntegration.options), + ) as never, + ); + for (const { integration } of itemForIntegration.integrations) { + const inputArray = Array.isArray(oneOrMultipleInputs) ? oneOrMultipleInputs : [oneOrMultipleInputs]; + + for (const input of inputArray) { + const inputHash = hashObjectBase64(input); + if ( + distinctIntegrations.some( + (distinctIntegration) => + distinctIntegration.integrationId === integration.id && distinctIntegration.inputHash === inputHash, + ) + ) { + continue; + } + + distinctIntegrations.push({ integrationId: integration.id, inputHash, integration, input }); + } + } + } + + for (const { integrationId, integration, input, inputHash } of distinctIntegrations) { + try { + const decryptedSecrets = integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })); + + const innerHandler = handler( + { + ...integration, + kind: integration.kind as TIntegrationKind, + decryptedSecrets, + }, + input, + ); + const stopWatch = new Stopwatch(); + await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); + logger.debug( + `Ran integration job integration=${integrationId} inputHash='${inputHash}' elapsed=${stopWatch.getElapsedInHumanWords()}`, + ); + } catch (error) { + logger.error( + `Failed to run integration job integration=${integrationId} inputHash='${inputHash}' error=${error as string}`, + ); + } + } + }; +}; diff --git a/packages/request-handler/src/media-request-list.ts b/packages/request-handler/src/media-request-list.ts new file mode 100644 index 000000000..53c31a7b8 --- /dev/null +++ b/packages/request-handler/src/media-request-list.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; +import type { MediaRequest } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const mediaRequestListRequestHandler = createCachedIntegrationRequestHandler< + MediaRequest[], + IntegrationKindByCategory<"mediaRequest">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = integrationCreator(integration); + return await integrationInstance.getRequestsAsync(); + }, + cacheDuration: dayjs.duration(1, "minute"), + queryKey: "mediaRequestList", +}); diff --git a/packages/request-handler/src/media-request-stats.ts b/packages/request-handler/src/media-request-stats.ts new file mode 100644 index 000000000..c4829167a --- /dev/null +++ b/packages/request-handler/src/media-request-stats.ts @@ -0,0 +1,23 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; +import type { MediaRequestStats } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHandler< + MediaRequestStats, + IntegrationKindByCategory<"mediaRequest">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = integrationCreator(integration); + return { + stats: await integrationInstance.getStatsAsync(), + users: await integrationInstance.getUsersAsync(), + }; + }, + cacheDuration: dayjs.duration(1, "minute"), + queryKey: "mediaRequestStats", +}); diff --git a/packages/request-handler/src/media-server.ts b/packages/request-handler/src/media-server.ts new file mode 100644 index 000000000..e2f52773f --- /dev/null +++ b/packages/request-handler/src/media-server.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import type { StreamSession } from "@homarr/integrations"; +import { integrationCreator } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const mediaServerRequestHandler = createCachedIntegrationRequestHandler< + StreamSession[], + IntegrationKindByCategory<"mediaService">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = integrationCreator(integration); + return await integrationInstance.getCurrentSessionsAsync(); + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "mediaServerSessions", +}); diff --git a/packages/request-handler/src/smart-home-entity-state.ts b/packages/request-handler/src/smart-home-entity-state.ts new file mode 100644 index 000000000..69641949c --- /dev/null +++ b/packages/request-handler/src/smart-home-entity-state.ts @@ -0,0 +1,25 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const smartHomeEntityStateRequestHandler = createCachedIntegrationRequestHandler< + string, + IntegrationKindByCategory<"smartHomeServer">, + { entityId: string } +>({ + async requestAsync(integration, input) { + const integrationInstance = integrationCreator(integration); + const result = await integrationInstance.getEntityStateAsync(input.entityId); + + if (!result.success) { + throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`); + } + + return result.data.state; + }, + cacheDuration: dayjs.duration(1, "minute"), + queryKey: "smartHome-entityState", +}); diff --git a/packages/request-handler/tsconfig.json b/packages/request-handler/tsconfig.json new file mode 100644 index 000000000..cbe8483d9 --- /dev/null +++ b/packages/request-handler/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 7910e01ea..c50a77c28 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2123,8 +2123,11 @@ "downloads": { "label": "Downloads" }, - "mediaRequests": { - "label": "Media Requests" + "mediaRequestStats": { + "label": "Media Request Stats" + }, + "mediaRequestList": { + "label": "Media Request List" }, "rssFeeds": { "label": "RSS feeds" diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 663a03780..3c45f761e 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -6,7 +6,8 @@ "type": "module", "exports": { ".": "./index.ts", - "./errors": "./src/errors/component.tsx" + "./errors": "./src/errors/component.tsx", + "./modals": "./src/modals/index.ts" }, "typesVersions": { "*": { diff --git a/packages/widgets/src/bookmarks/add-button.tsx b/packages/widgets/src/bookmarks/add-button.tsx new file mode 100644 index 000000000..738355cb9 --- /dev/null +++ b/packages/widgets/src/bookmarks/add-button.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Button } from "@mantine/core"; + +import { useModalAction } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; + +import type { SortableItemListInput } from "../options"; +import { AppSelectModal } from "./app-select-modal"; + +export const BookmarkAddButton: SortableItemListInput< + { name: string; description: string | null; id: string; iconUrl: string; href: string | null }, + string +>["AddButton"] = ({ addItem, values }) => { + const { openModal } = useModalAction(AppSelectModal); + const t = useI18n(); + + return ( + + ); +}; diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index 8c6288ed0..dccd16e3d 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -1,14 +1,12 @@ -import { ActionIcon, Avatar, Button, Group, Stack, Text } from "@mantine/core"; +import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core"; import { IconClock, IconX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { useModalAction } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; -import { AppSelectModal } from "./app-select-modal"; +import { BookmarkAddButton } from "./add-button"; export const { definition, componentLoader } = createWidgetDefinition("bookmarks", { icon: IconClock, @@ -42,16 +40,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks ); }, - AddButton({ addItem, values }) { - const { openModal } = useModalAction(AppSelectModal); - const t = useI18n(); - - return ( - - ); - }, + AddButton: BookmarkAddButton, uniqueIdentifier: (item) => item.id, useData: (initialIds) => { const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds); diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index bcdca811c..d2eb3c780 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -12,17 +12,14 @@ import type { WidgetComponentProps } from "../definition"; import { CalendarDay } from "./calender-day"; import classes from "./component.module.css"; -export default function CalendarWidget({ - isEditMode, - integrationIds, - itemId, - options, -}: WidgetComponentProps<"calendar">) { +export default function CalendarWidget({ isEditMode, integrationIds, options }: WidgetComponentProps<"calendar">) { + const [month, setMonth] = useState(new Date()); const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery( { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - itemId: itemId!, integrationIds, + month: month.getMonth(), + year: month.getFullYear(), + releaseType: options.releaseType, }, { refetchOnMount: false, @@ -31,7 +28,6 @@ export default function CalendarWidget({ retry: false, }, ); - const [month, setMonth] = useState(new Date()); const params = useParams(); const locale = params.locale as string; const [firstDayOfWeek] = clientApi.user.getFirstDayOfWeekForUserOrDefault.useSuspenseQuery(); diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 06e41ea7b..36324d329 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -18,16 +18,16 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react"; -import dayjs from "dayjs"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; +import { useIntegrationConnected } from "@homarr/common"; import { integrationDefs } from "@homarr/definitions"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; -import { widgetKind } from "."; +import type { widgetKind } from "."; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationSelectedError } from "../../errors"; import TimerModal from "./TimerModal"; @@ -47,7 +47,6 @@ export default function DnsHoleControlsWidget({ const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery( { - widgetKind: "dnsHoleControls", integrationIds, }, { @@ -61,21 +60,27 @@ export default function DnsHoleControlsWidget({ // Subscribe to summary updates clientApi.widget.dnsHole.subscribeToSummary.useSubscription( { - widgetKind, integrationIds, }, { onData: (data) => { utils.widget.dnsHole.summary.setData( { - widgetKind: "dnsHoleControls", integrationIds, }, (prevData) => { if (!prevData) return undefined; const newData = prevData.map((summary) => - summary.integration.id === data.integration.id ? { ...summary, summary: data.summary } : summary, + summary.integration.id === data.integration.id + ? { + integration: { + ...summary.integration, + updatedAt: new Date(), + }, + summary: data.summary, + } + : summary, ); return newData; @@ -90,14 +95,13 @@ export default function DnsHoleControlsWidget({ onSettled: (_, error, { integrationId }) => { utils.widget.dnsHole.summary.setData( { - widgetKind: "dnsHoleControls", integrationIds, }, (prevData) => { if (!prevData) return []; return prevData.map((item) => - item.integration.id === integrationId && item.summary + item.integration.id === integrationId ? { ...item, summary: { @@ -115,14 +119,13 @@ export default function DnsHoleControlsWidget({ onSettled: (_, error, { integrationId }) => { utils.widget.dnsHole.summary.setData( { - widgetKind: "dnsHoleControls", integrationIds, }, (prevData) => { if (!prevData) return []; return prevData.map((item) => - item.integration.id === integrationId && item.summary + item.integration.id === integrationId ? { ...item, summary: { @@ -138,17 +141,16 @@ export default function DnsHoleControlsWidget({ }); const toggleDns = (integrationId: string) => { const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId); - if (!integrationStatus?.summary?.status) return; + if (!integrationStatus?.summary.status) return; utils.widget.dnsHole.summary.setData( { - widgetKind: "dnsHoleControls", integrationIds, }, (prevData) => { if (!prevData) return []; return prevData.map((item) => - item.integration.id === integrationId && item.summary + item.integration.id === integrationId ? { ...item, summary: { @@ -170,7 +172,7 @@ export default function DnsHoleControlsWidget({ // make lists of enabled and disabled interactable integrations (with permissions, not disconnected and not processing) const integrationsSummaries = summaries.reduce( (acc, { summary, integration: { id } }) => - integrationsWithInteractions.includes(id) && summary?.status != null ? (acc[summary.status].push(id), acc) : acc, + integrationsWithInteractions.includes(id) && summary.status != null ? (acc[summary.status].push(id), acc) : acc, { enabled: [] as string[], disabled: [] as string[] }, ); @@ -310,9 +312,8 @@ const ControlsCard: React.FC = ({ open, t, }) => { - // Independently determine connection status, current state and permissions - const isConnected = data.summary !== null && Math.abs(dayjs(data.timestamp).diff()) < 30000; - const isEnabled = data.summary?.status ? data.summary.status === "enabled" : undefined; + const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 }); + const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined; const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id); // Use all factors to infer the state of the action buttons const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected; @@ -355,7 +356,7 @@ const ControlsCard: React.FC = ({ lts="0.1cqmin" color="var(--background-color)" c="var(--mantine-color-text)" - styles={{ section: { marginInlineEnd: "2.5cqmin" } }} + styles={{ section: { marginInlineEnd: "2.5cqmin" }, root: { cursor: "inherit" } }} leftSection={ isConnected && ( ) { const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery( { - widgetKind, integrationIds, }, { @@ -39,14 +37,12 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget clientApi.widget.dnsHole.subscribeToSummary.useSubscription( { - widgetKind, integrationIds, }, { onData: (data) => { utils.widget.dnsHole.summary.setData( { - widgetKind, integrationIds, }, (prevData) => { @@ -64,14 +60,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget }, ); - const data = useMemo( - () => - summaries - .filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000) - .flatMap(({ summary }) => summary) - .filter((summary) => summary !== null), - [summaries], - ); + const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]); if (integrationIds.length === 0) { throw new NoIntegrationSelectedError(); diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index e437787ef..37aac837c 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -21,7 +21,7 @@ import { Title, Tooltip, } from "@mantine/core"; -import { useDisclosure, useTimeout } from "@mantine/hooks"; +import { useDisclosure } from "@mantine/hooks"; import type { IconProps } from "@tabler/icons-react"; import { IconAlertTriangle, @@ -39,13 +39,9 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; -import { humanFileSize } from "@homarr/common"; +import { humanFileSize, useIntegrationConnected } from "@homarr/common"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { - DownloadClientJobsAndStatus, - ExtendedClientStatus, - ExtendedDownloadClientItem, -} from "@homarr/integrations"; +import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; @@ -82,8 +78,6 @@ const standardIconStyle: IconProps["style"] = { width: "var(--icon-size)", }; -const invalidateTime = 30000; - export default function DownloadClientsWidget({ isEditMode, integrationIds, @@ -103,26 +97,10 @@ export default function DownloadClientsWidget({ refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, - select(data) { - return data.map((item) => - dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null }, - ); - }, }, ); const utils = clientApi.useUtils(); - //Invalidate all data after no update for 30 seconds using timer - const invalidationTimer = useTimeout( - () => { - utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => - prevData?.map((item) => ({ ...item, timestamp: new Date(0), data: null })), - ); - }, - invalidateTime, - { autoInvoke: true }, - ); - //Translations const t = useScopedI18n("widget.downloads"); const tCommon = useScopedI18n("common"); @@ -143,32 +121,19 @@ export default function DownloadClientsWidget({ }, { onData: (data) => { - //Use cyclical update to invalidate data older than 30 seconds from unresponsive integrations - const invalidIndexes = currentItems - //Don't update already invalid data (new Date (0)) - .filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0)) - .map(({ integration }) => integration.id); - utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => - prevData?.map((item) => - invalidIndexes.includes(item.integration.id) ? item : { ...item, timestamp: new Date(0), data: null }, - ), - ); utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => { - const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id); - if (updateIndex >= 0) { - //Update found index - return prevData?.map((pair, index) => (index === updateIndex ? data : pair)); - } else if (integrationIds.includes(data.integration.id)) { - //Append index not found (new integration) - return [...(prevData ?? []), data]; - } + return prevData?.map((item) => { + if (item.integration.id !== data.integration.id) return item; - return undefined; + return { + data: data.data, + integration: { + ...data.integration, + updatedAt: new Date(), + }, + }; + }); }); - - //Reset no update timer - invalidationTimer.clear(); - invalidationTimer.start(); }, }, ); @@ -179,16 +144,6 @@ export default function DownloadClientsWidget({ currentItems //Insure it is only using selected integrations .filter(({ integration }) => integrationIds.includes(integration.id)) - //Removing any integration with no data associated - .filter( - ( - pair, - ): pair is { - integration: typeof pair.integration; - timestamp: typeof pair.timestamp; - data: DownloadClientJobsAndStatus; - } => pair.data != null, - ) //Construct normalized items list .flatMap((pair) => //Apply user white/black list @@ -255,7 +210,6 @@ export default function DownloadClientsWidget({ .filter(({ integration }) => integrationIds.includes(integration.id)) .flatMap(({ integration, data }): ExtendedClientStatus => { const interact = integrationsWithInteractions.includes(integration.id); - if (!data) return { integration, interact }; const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind); /** Derived from current items */ const { totalUp, totalDown } = data.items @@ -821,12 +775,7 @@ const ClientsControl = ({ clients, style }: ClientsControlProps) => { {clients.map((client) => ( - + ))} {someInteract && ( @@ -939,3 +888,21 @@ const ClientsControl = ({ clients, style }: ClientsControlProps) => { ); }; + +interface ClientAvatarProps { + client: ExtendedClientStatus; +} + +const ClientAvatar = ({ client }: ClientAvatarProps) => { + const isConnected = useIntegrationConnected(client.integration.updatedAt, { timeout: 30000 }); + + return ( + + ); +}; diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index 2c6f21fd7..c9c7aa946 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -33,7 +33,6 @@ import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import { clientApi } from "@homarr/api/client"; -import type { HealthMonitoring } from "@homarr/integrations"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; @@ -53,17 +52,6 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, - select: (data) => - data.filter( - ( - health, - ): health is { - integrationId: string; - integrationName: string; - healthInfo: HealthMonitoring; - timestamp: Date; - } => health.healthInfo !== null, - ), }, ); const [opened, { open, close }] = useDisclosure(false); @@ -82,16 +70,7 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg ? { ...item, healthInfo: data.healthInfo, timestamp: data.timestamp } : item, ); - return newData.filter( - ( - health, - ): health is { - integrationId: string; - integrationName: string; - healthInfo: HealthMonitoring; - timestamp: Date; - } => health.healthInfo !== null, - ); + return newData; }); }, }, @@ -102,7 +81,7 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg } return ( - {healthData.map(({ integrationId, integrationName, healthInfo, timestamp }) => { + {healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => { const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart); const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed); return ( @@ -211,15 +190,17 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg )} {options.memory && } - - {t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(timestamp).fromNow() })} - + { + + {t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })} + + } {options.fileSystem && disksData.map((disk) => { diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx index e8af4d864..b561bd060 100644 --- a/packages/widgets/src/iframe/component.tsx +++ b/packages/widgets/src/iframe/component.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Box, Stack, Text, Title } from "@mantine/core"; import { IconBrowserOff } from "@tabler/icons-react"; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index e4876cfee..e2d83563a 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -3,7 +3,8 @@ import type { Loader } from "next/dynamic"; import dynamic from "next/dynamic"; import { Center, Loader as UiLoader } from "@mantine/core"; -import type { WidgetKind } from "@homarr/definitions"; +import { objectEntries } from "@homarr/common"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import * as app from "./app"; import * as bookmarks from "./bookmarks"; @@ -21,16 +22,14 @@ import * as mediaRequestsList from "./media-requests/list"; import * as mediaRequestsStats from "./media-requests/stats"; import * as mediaServer from "./media-server"; import * as notebook from "./notebook"; +import type { WidgetOptionDefinition } from "./options"; import * as rssFeed from "./rssFeed"; import * as smartHomeEntityState from "./smart-home/entity-state"; import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; import * as video from "./video"; import * as weather from "./weather"; -export { reduceWidgetOptionsWithDefaultValues } from "./options"; - export type { WidgetDefinition } from "./definition"; -export { WidgetEditModal } from "./modals/widget-edit-modal"; export type { WidgetComponentProps }; export const widgetImports = { @@ -84,3 +83,21 @@ export type inferSupportedIntegrations = (WidgetImport } ? WidgetImports[TKind]["definition"]["supportedIntegrations"] : string[])[number]; + +export type inferSupportedIntegrationsStrict = (WidgetImports[TKind]["definition"] extends { + supportedIntegrations: IntegrationKind[]; +} + ? WidgetImports[TKind]["definition"]["supportedIntegrations"] + : never[])[number]; + +export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record = {}) => { + const definition = widgetImports[kind].definition; + const options = definition.options as Record; + return objectEntries(options).reduce( + (prev, [key, value]) => ({ + ...prev, + [key]: currentValue[key] ?? value.defaultValue, + }), + {} as Record, + ); +}; diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index ce67166ba..301c98813 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -1,4 +1,5 @@ -import { useMemo } from "react"; +"use client"; + import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; import { IconThumbDown, IconThumbUp } from "@tabler/icons-react"; @@ -15,14 +16,11 @@ export default function MediaServerWidget({ integrationIds, isEditMode, options, - itemId, }: WidgetComponentProps<"mediaRequests-requestList">) { const t = useScopedI18n("widget.mediaRequests-requestList"); const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery( { integrationIds, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - itemId: itemId!, }, { refetchOnMount: false, @@ -30,30 +28,39 @@ export default function MediaServerWidget({ refetchOnReconnect: false, }, ); + const utils = clientApi.useUtils(); + clientApi.widget.mediaRequests.subscribeToLatestRequests.useSubscription( + { + integrationIds, + }, + { + onData(data) { + utils.widget.mediaRequests.getLatestRequests.setData({ integrationIds }, (prevData) => { + if (!prevData) return []; - const sortedMediaRequests = useMemo( - () => - mediaRequests - .filter((group) => group != null) - .flatMap((group) => group.data) - .flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id }))) - .sort(({ status: statusA }, { status: statusB }) => { - if (statusA === MediaRequestStatus.PendingApproval) { - return -1; - } - if (statusB === MediaRequestStatus.PendingApproval) { - return 1; - } - return 0; - }), - [mediaRequests], + const filteredData = prevData.filter(({ integrationId }) => integrationId !== data.integrationId); + const newData = filteredData.concat( + data.requests.map((request) => ({ ...request, integrationId: data.integrationId })), + ); + return newData.sort(({ status: statusA }, { status: statusB }) => { + if (statusA === MediaRequestStatus.PendingApproval) { + return -1; + } + if (statusB === MediaRequestStatus.PendingApproval) { + return 1; + } + return 0; + }); + }); + }, + }, ); const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation(); if (integrationIds.length === 0) throw new NoIntegrationSelectedError(); - if (sortedMediaRequests.length === 0) throw new NoIntegrationDataError(); + if (mediaRequests.length === 0) throw new NoIntegrationDataError(); return ( - {sortedMediaRequests.map((mediaRequest) => ( + {mediaRequests.map((mediaRequest) => ( ) { const t = useScopedI18n("widget.mediaRequests-requestStats"); const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery( { integrationIds, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - itemId: itemId!, }, { refetchOnMount: false, @@ -45,67 +43,51 @@ export default function MediaServerWidget({ const { width, height, ref } = useElementSize(); - const baseData = useMemo( - () => requestStats.filter((group) => group != null).flatMap((group) => group.data), - [requestStats], - ); - - const stats = useMemo(() => baseData.flatMap(({ stats }) => stats), [baseData]); - const users = useMemo( - () => - baseData - .flatMap(({ integration, users }) => - users.flatMap((user) => ({ ...user, appKind: integration.kind, appName: integration.name })), - ) - .sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA), - [baseData], - ); - if (integrationIds.length === 0) throw new NoIntegrationSelectedError(); - if (users.length === 0 || stats.length === 0) throw new NoIntegrationDataError(); + if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError(); //Add processing and available const data = [ { name: "approved", icon: IconThumbUp, - number: stats.reduce((count, { approved }) => count + approved, 0), + number: requestStats.stats.reduce((count, { approved }) => count + approved, 0), }, { name: "pending", icon: IconHourglass, - number: stats.reduce((count, { pending }) => count + pending, 0), + number: requestStats.stats.reduce((count, { pending }) => count + pending, 0), }, { name: "processing", icon: IconLoaderQuarter, - number: stats.reduce((count, { processing }) => count + processing, 0), + number: requestStats.stats.reduce((count, { processing }) => count + processing, 0), }, { name: "declined", icon: IconThumbDown, - number: stats.reduce((count, { declined }) => count + declined, 0), + number: requestStats.stats.reduce((count, { declined }) => count + declined, 0), }, { name: "available", icon: IconPlayerPlay, - number: stats.reduce((count, { available }) => count + available, 0), + number: requestStats.stats.reduce((count, { available }) => count + available, 0), }, { name: "tv", icon: IconDeviceTv, - number: stats.reduce((count, { tv }) => count + tv, 0), + number: requestStats.stats.reduce((count, { tv }) => count + tv, 0), }, { name: "movie", icon: IconMovie, - number: stats.reduce((count, { movie }) => count + movie, 0), + number: requestStats.stats.reduce((count, { movie }) => count + movie, 0), }, { name: "total", icon: IconReceipt, - number: stats.reduce((count, { total }) => count + total, 0), + number: requestStats.stats.reduce((count, { total }) => count + total, 0), }, ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; @@ -156,7 +138,7 @@ export default function MediaServerWidget({ gap="2cqmin" style={{ overflow: "hidden" }} > - {users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => ( + {requestStats.users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => ( - + diff --git a/packages/widgets/src/modals/index.ts b/packages/widgets/src/modals/index.ts new file mode 100644 index 000000000..7ea1185af --- /dev/null +++ b/packages/widgets/src/modals/index.ts @@ -0,0 +1 @@ +export * from "./widget-edit-modal"; diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index d65296dd8..3314a121f 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -2,12 +2,10 @@ import type React from "react"; import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core"; import type { ActionIconProps } from "@mantine/core"; -import { objectEntries } from "@homarr/common"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import type { IntegrationKind } from "@homarr/definitions"; import type { ZodType } from "@homarr/validation"; import { z } from "@homarr/validation"; -import { widgetImports } from "."; import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input"; interface CommonInput { @@ -25,7 +23,7 @@ interface MultiSelectInput searchable?: boolean; } -interface SortableItemListInput +export interface SortableItemListInput extends Omit, "withDescription"> { AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode; ItemComponent: (props: { @@ -188,15 +186,3 @@ export type OptionsBuilderResult = ReturnType; export const optionsBuilder = { from: createOptions, }; - -export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record = {}) => { - const definition = widgetImports[kind].definition; - const options = definition.options as Record; - return objectEntries(options).reduce( - (prev, [key, value]) => ({ - ...prev, - [key]: currentValue[key] ?? value.defaultValue, - }), - {} as Record, - ); -}; diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index e1ce9efc9..a765503df 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core"; import { IconClock } from "@tabler/icons-react"; import dayjs from "dayjs"; diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index b627e5f73..410b6596c 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -1,41 +1,48 @@ "use client"; -import React, { useCallback, useState } from "react"; +import { useCallback } from "react"; import { Center, Stack, Text, UnstyledButton } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import type { WidgetComponentProps } from "../../definition"; +import { NoIntegrationSelectedError } from "../../errors"; export default function SmartHomeEntityStateWidget({ options, integrationIds, isEditMode, }: WidgetComponentProps<"smartHome-entityState">) { - const [lastState, setLastState] = useState<{ - entityId: string; - state: string; - }>(); + const integrationId = integrationIds[0]; + + if (!integrationId) { + throw new NoIntegrationSelectedError(); + } + + return ; +} + +type InnerComponentProps = Pick, "options" | "isEditMode"> & { + integrationId: string; +}; + +const InnerComponent = ({ options, integrationId, isEditMode }: InnerComponentProps) => { + const input = { + entityId: options.entityId, + integrationId, + }; + const [entityState] = clientApi.widget.smartHome.entityState.useSuspenseQuery(input); const utils = clientApi.useUtils(); - clientApi.widget.smartHome.subscribeEntityState.useSubscription( - { - entityId: options.entityId, - }, - { - onData(data) { - setLastState(data); - }, - }, - ); - - const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({ - onSettled: () => { - void utils.widget.smartHome.invalidate(); + clientApi.widget.smartHome.subscribeEntityState.useSubscription(input, { + onData(data) { + utils.widget.smartHome.entityState.setData(input, data.state); }, }); + const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation(); + const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : ""; const handleClick = useCallback(() => { @@ -49,9 +56,9 @@ export default function SmartHomeEntityStateWidget({ mutate({ entityId: options.entityId, - integrationId: integrationIds[0] ?? "", + integrationId, }); - }, [integrationIds, isEditMode, mutate, options.clickable, options.entityId]); + }, [integrationId, isEditMode, mutate, options.clickable, options.entityId]); return ( - {lastState?.state} + {entityState} {attribute} ); -} +}; diff --git a/packages/widgets/src/smart-home/execute-automation/component.tsx b/packages/widgets/src/smart-home/execute-automation/component.tsx index e174cdb2b..a2cd856d9 100644 --- a/packages/widgets/src/smart-home/execute-automation/component.tsx +++ b/packages/widgets/src/smart-home/execute-automation/component.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import { ActionIcon, Center, LoadingOverlay, Overlay, Stack, Text, UnstyledButton } from "@mantine/core"; import { useDisclosure, useTimeout } from "@mantine/hooks"; diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 9f5da3ade..dfff28da6 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core"; import { IconArrowDownRight, IconArrowUpRight, IconMapPin } from "@tabler/icons-react"; import combineClasses from "clsx"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2219ff613..30c230e44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -497,6 +497,9 @@ importers: '@homarr/redis': specifier: workspace:^0.1.0 version: link:../redis + '@homarr/request-handler': + specifier: workspace:^0.1.0 + version: link:../request-handler '@homarr/server-settings': specifier: workspace:^0.1.0 version: link:../server-settings @@ -782,6 +785,9 @@ importers: '@homarr/redis': specifier: workspace:^0.1.0 version: link:../redis + '@homarr/request-handler': + specifier: workspace:^0.1.0 + version: link:../request-handler '@homarr/server-settings': specifier: workspace:^0.1.0 version: link:../server-settings @@ -1311,6 +1317,49 @@ importers: specifier: ^5.6.3 version: 5.6.3 + packages/request-handler: + dependencies: + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common + '@homarr/db': + specifier: workspace:^0.1.0 + version: link:../db + '@homarr/definitions': + specifier: workspace:^0.1.0 + version: link:../definitions + '@homarr/integrations': + specifier: workspace:^0.1.0 + version: link:../integrations + '@homarr/log': + specifier: workspace:^0.1.0 + version: link:../log + '@homarr/redis': + specifier: workspace:^0.1.0 + version: link:../redis + dayjs: + specifier: ^1.11.13 + version: 1.11.13 + superjson: + specifier: 2.2.1 + version: 2.2.1 + devDependencies: + '@homarr/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@homarr/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@homarr/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + eslint: + specifier: ^9.15.0 + version: 9.15.0 + typescript: + specifier: ^5.6.3 + version: 5.6.3 + packages/server-settings: dependencies: '@homarr/definitions': diff --git a/turbo/generators/templates/package.json.hbs b/turbo/generators/templates/package.json.hbs index f870e9cd2..b8fda8d8e 100644 --- a/turbo/generators/templates/package.json.hbs +++ b/turbo/generators/templates/package.json.hbs @@ -24,8 +24,8 @@ "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", - "eslint": "^9.10.0", - "typescript": "^5.6.2" + "eslint": "^9.15.0", + "typescript": "^5.6.3" }, "prettier": "@homarr/prettier-config" } \ No newline at end of file