mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
refactor: add request handlers for centralized cached requests (#1504)
* feat: add object base64 hash method * chore: add script to add package * feat: add request-handler package * wip: add request handlers for all jobs and widget api procedures * wip: remove errors shown in logs, add missing decryption for secrets in cached-request-job-handler * wip: highly improve request handler, add request handlers for calendar, media-server, indexer-manager and more, add support for multiple inputs from job handler creator * refactor: move media-server requests to request-handler, add invalidation logic for dns-hole and media requests * refactor: remove unused integration item middleware * feat: add invalidation to switch entity action of smart-home * fix: lint issues * chore: use integration-kind-by-category instead of union for request-handlers * fix: build not working for tasks and websocket * refactor: add more logs * refactor: readd timestamp logic for diconnect status * fix: lint and typecheck issue * chore: address pull request feedback
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<AppRouter>({
|
||||
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"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -150,83 +150,6 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return publicProcedure
|
||||
.input(z.object({ integrationIds: z.array(z.string()).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
|
||||
|
||||
@@ -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<CalendarEvent[]>("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();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<DnsHoleSummary>(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<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
|
||||
timestamp: Date;
|
||||
summary: DnsHoleSummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(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<void>((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<void>((resolve) => {
|
||||
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<DownloadClientJobsAndStatus>("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<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
||||
timestamp: Date;
|
||||
data: DownloadClientJobsAndStatus;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>("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>("healthMonitoring", integration.id);
|
||||
const unsubscribe = channel.subscribe((healthInfo) => {
|
||||
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
||||
const unsubscribe = innerHandler.subscribe((healthInfo) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
healthInfo,
|
||||
|
||||
@@ -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<Indexer[]>("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 }) => {
|
||||
|
||||
@@ -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<MediaRequestList>("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<MediaRequestStats>(
|
||||
"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();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<StreamSession[]>("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<StreamSession[]>("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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hashKey } from "@tanstack/query-core";
|
||||
|
||||
export function objectKeys<O extends object>(obj: O): (keyof O)[] {
|
||||
return Object.keys(obj) as (keyof O)[];
|
||||
}
|
||||
@@ -7,3 +9,7 @@ type Entries<T> = {
|
||||
}[keyof T][];
|
||||
|
||||
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Entries<T>;
|
||||
|
||||
export const hashObjectBase64 = (obj: object) => {
|
||||
return Buffer.from(hashKey([obj])).toString("base64");
|
||||
};
|
||||
|
||||
@@ -9,3 +9,5 @@ export type Modify<T, R extends Partial<Record<keyof T, unknown>>> = {
|
||||
export type RemoveReadonly<T> = {
|
||||
-readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P];
|
||||
};
|
||||
|
||||
export type MaybeArray<T> = T | T[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DnsHoleSummary>(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: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<DownloadClientJobsAndStatus>("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: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<WidgetComponentProps<"smartHome-entityState">["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,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<WidgetComponentProps<"calendar">["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<CalendarEvent[]>("calendar", integration.id);
|
||||
await cache.setAsync(events);
|
||||
}
|
||||
}
|
||||
});
|
||||
return inputs;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<MediaRequestList>(
|
||||
"mediaRequests-requestList",
|
||||
integration.id,
|
||||
);
|
||||
await requestListChannel.publishAndUpdateLastStateAsync({
|
||||
integration: { id: integration.id },
|
||||
medias: mediaRequests,
|
||||
});
|
||||
|
||||
const requestStatsChannel = createItemAndIntegrationChannel<MediaRequestStats>(
|
||||
"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": () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const downloadClientItemSchema = z.object({
|
||||
export type DownloadClientItem = z.infer<typeof downloadClientItemSchema>;
|
||||
|
||||
export type ExtendedDownloadClientItem = {
|
||||
integration: Integration;
|
||||
integration: Pick<Integration, "id" | "name" | "kind">;
|
||||
received: number;
|
||||
ratio?: number;
|
||||
actions?: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface DownloadClientStatus {
|
||||
type: "usenet" | "torrent";
|
||||
}
|
||||
export interface ExtendedClientStatus {
|
||||
integration: Integration;
|
||||
integration: Pick<Integration, "id" | "name" | "kind"> & { updatedAt: Date };
|
||||
interact: boolean;
|
||||
status?: {
|
||||
/** To derive from current items */
|
||||
|
||||
@@ -39,10 +39,6 @@ export interface RequestUser {
|
||||
}
|
||||
|
||||
export interface MediaRequestStats {
|
||||
integration: {
|
||||
kind: string;
|
||||
name: string;
|
||||
};
|
||||
stats: RequestStats;
|
||||
users: RequestUser[];
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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}'`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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 = <TData>(kind: WidgetKind, integra
|
||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
||||
};
|
||||
|
||||
export const createIntegrationOptionsChannel = <TData>(
|
||||
integrationId: string,
|
||||
queryKey: string,
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
const optionsKey = hashObjectBase64(options);
|
||||
const channelName = `integration:${integrationId}:${queryKey}:options:${optionsKey}`;
|
||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
||||
};
|
||||
|
||||
export const createItemChannel = <TData>(itemId: string) => {
|
||||
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
||||
};
|
||||
|
||||
const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
|
||||
export const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
|
||||
return {
|
||||
subscribe: (callback: (data: TData) => void) => {
|
||||
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
||||
@@ -196,6 +207,7 @@ const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
|
||||
|
||||
return superjson.parse<{ data: TData; timestamp: Date }>(data);
|
||||
},
|
||||
name: channelName,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
9
packages/request-handler/eslint.config.js
Normal file
9
packages/request-handler/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
41
packages/request-handler/package.json
Normal file
41
packages/request-handler/package.json
Normal file
@@ -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"
|
||||
}
|
||||
22
packages/request-handler/src/calendar.ts
Normal file
22
packages/request-handler/src/calendar.ts
Normal file
@@ -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",
|
||||
});
|
||||
20
packages/request-handler/src/dns-hole.ts
Normal file
20
packages/request-handler/src/dns-hole.ts
Normal file
@@ -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<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
return await integrationInstance.getSummaryAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "dnsHoleSummary",
|
||||
});
|
||||
20
packages/request-handler/src/downloads.ts
Normal file
20
packages/request-handler/src/downloads.ts
Normal file
@@ -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<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
return await integrationInstance.getClientJobsAndStatusAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "downloadClientJobStatus",
|
||||
});
|
||||
20
packages/request-handler/src/health-monitoring.ts
Normal file
20
packages/request-handler/src/health-monitoring.ts
Normal file
@@ -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<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
return await integrationInstance.getSystemInfoAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "systemInfo",
|
||||
});
|
||||
20
packages/request-handler/src/indexer-manager.ts
Normal file
20
packages/request-handler/src/indexer-manager.ts
Normal file
@@ -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<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
return await integrationInstance.getIndexersAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "indexerManager",
|
||||
});
|
||||
@@ -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<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
|
||||
kind: TKind;
|
||||
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
|
||||
};
|
||||
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
// Unique key for this request handler
|
||||
queryKey: string;
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheDuration: Duration;
|
||||
}
|
||||
|
||||
export const createCachedIntegrationRequestHandler = <
|
||||
TData,
|
||||
TKind extends IntegrationKind,
|
||||
TInput extends Record<string, unknown>,
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
|
||||
createCachedRequestHandler({
|
||||
queryKey: options.queryKey,
|
||||
requestAsync: async (input: { options: TInput; integration: IntegrationOfKind<TKind> }) => {
|
||||
return await options.requestAsync(input.integration, input.options);
|
||||
},
|
||||
cacheDuration: options.cacheDuration,
|
||||
createRedisChannel(input, options) {
|
||||
return createIntegrationOptionsChannel<TData>(input.integration.id, options.queryKey, input.options);
|
||||
},
|
||||
}).handler({ options: itemOptions, integration }),
|
||||
};
|
||||
};
|
||||
74
packages/request-handler/src/lib/cached-request-handler.ts
Normal file
74
packages/request-handler/src/lib/cached-request-handler.ts
Normal file
@@ -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<TData, TInput extends Record<string, unknown>> {
|
||||
// Unique key for this request handler
|
||||
queryKey: string;
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
createRedisChannel: (
|
||||
input: TInput,
|
||||
options: Options<TData, TInput>,
|
||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||
cacheDuration: Duration;
|
||||
}
|
||||
|
||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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<TWidgetKind>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
THandler extends ReturnType<typeof createCachedIntegrationRequestHandler<any, TIntegrationKind, any>>["handler"],
|
||||
>(
|
||||
handler: THandler,
|
||||
{
|
||||
widgetKinds,
|
||||
getInput,
|
||||
}: {
|
||||
widgetKinds: TWidgetKind[];
|
||||
getInput: {
|
||||
[key in TWidgetKind]: (options: WidgetComponentProps<key>["options"]) => MaybeArray<Parameters<THandler>[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<THandler>[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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
20
packages/request-handler/src/media-request-list.ts
Normal file
20
packages/request-handler/src/media-request-list.ts
Normal file
@@ -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<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
return await integrationInstance.getRequestsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "mediaRequestList",
|
||||
});
|
||||
23
packages/request-handler/src/media-request-stats.ts
Normal file
23
packages/request-handler/src/media-request-stats.ts
Normal file
@@ -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<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
return {
|
||||
stats: await integrationInstance.getStatsAsync(),
|
||||
users: await integrationInstance.getUsersAsync(),
|
||||
};
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "mediaRequestStats",
|
||||
});
|
||||
20
packages/request-handler/src/media-server.ts
Normal file
20
packages/request-handler/src/media-server.ts
Normal file
@@ -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<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
return await integrationInstance.getCurrentSessionsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "mediaServerSessions",
|
||||
});
|
||||
25
packages/request-handler/src/smart-home-entity-state.ts
Normal file
25
packages/request-handler/src/smart-home-entity-state.ts
Normal file
@@ -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",
|
||||
});
|
||||
8
packages/request-handler/tsconfig.json
Normal file
8
packages/request-handler/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
"*": {
|
||||
|
||||
23
packages/widgets/src/bookmarks/add-button.tsx
Normal file
23
packages/widgets/src/bookmarks/add-button.tsx
Normal file
@@ -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 (
|
||||
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
|
||||
{t("widget.bookmarks.option.items.add")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
AddButton({ addItem, values }) {
|
||||
const { openModal } = useModalAction(AppSelectModal);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
|
||||
{t("widget.bookmarks.option.items.add")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
AddButton: BookmarkAddButton,
|
||||
uniqueIdentifier: (item) => item.id,
|
||||
useData: (initialIds) => {
|
||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ControlsCardProps> = ({
|
||||
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<ControlsCardProps> = ({
|
||||
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 && (
|
||||
<IconCircleFilled
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { BoxProps } from "@mantine/core";
|
||||
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatNumber } from "@homarr/common";
|
||||
@@ -16,14 +15,13 @@ import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { widgetKind } from ".";
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
import { NoIntegrationSelectedError } from "../../errors";
|
||||
|
||||
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
|
||||
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();
|
||||
|
||||
@@ -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) => {
|
||||
<Group gap="var(--space-size)" style={style}>
|
||||
<AvatarGroup spacing="calc(var(--space-size)*2)">
|
||||
{clients.map((client) => (
|
||||
<Avatar
|
||||
key={client.integration.id}
|
||||
src={getIconUrl(client.integration.kind)}
|
||||
size="var(--image-size)"
|
||||
bd={client.status ? 0 : "calc(var(--space-size)*0.5) solid var(--mantine-color-red-filled)"}
|
||||
/>
|
||||
<ClientAvatar key={client.integration.id} client={client} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{someInteract && (
|
||||
@@ -939,3 +888,21 @@ const ClientsControl = ({ clients, style }: ClientsControlProps) => {
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface ClientAvatarProps {
|
||||
client: ExtendedClientStatus;
|
||||
}
|
||||
|
||||
const ClientAvatar = ({ client }: ClientAvatarProps) => {
|
||||
const isConnected = useIntegrationConnected(client.integration.updatedAt, { timeout: 30000 });
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
key={client.integration.id}
|
||||
src={getIconUrl(client.integration.kind)}
|
||||
style={{ filter: !isConnected ? "grayscale(100%)" : undefined }}
|
||||
size="var(--image-size)"
|
||||
bd={client.status ? 0 : "calc(var(--space-size)*0.5) solid var(--mantine-color-red-filled)"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Stack h="100%" gap="2.5cqmin" className="health-monitoring">
|
||||
{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 && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
|
||||
</Flex>
|
||||
<Text
|
||||
className="health-monitoring-status-update-time"
|
||||
c="dimmed"
|
||||
size="3.5cqmin"
|
||||
ta="center"
|
||||
mb="2.5cqmin"
|
||||
>
|
||||
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(timestamp).fromNow() })}
|
||||
</Text>
|
||||
{
|
||||
<Text
|
||||
className="health-monitoring-status-update-time"
|
||||
c="dimmed"
|
||||
size="3.5cqmin"
|
||||
ta="center"
|
||||
mb="2.5cqmin"
|
||||
>
|
||||
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
|
||||
</Text>
|
||||
}
|
||||
</Card>
|
||||
{options.fileSystem &&
|
||||
disksData.map((disk) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconBrowserOff } from "@tabler/icons-react";
|
||||
|
||||
|
||||
@@ -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<TKind extends WidgetKind> = (WidgetImport
|
||||
}
|
||||
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||
: string[])[number];
|
||||
|
||||
export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
}
|
||||
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||
: never[])[number];
|
||||
|
||||
export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record<string, unknown> = {}) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const options = definition.options as Record<string, WidgetOptionDefinition>;
|
||||
return objectEntries(options).reduce(
|
||||
(prev, [key, value]) => ({
|
||||
...prev,
|
||||
[key]: currentValue[key] ?? value.defaultValue,
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<ScrollArea
|
||||
@@ -62,7 +69,7 @@ export default function MediaServerWidget({
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="2cqmin" p="2cqmin">
|
||||
{sortedMediaRequests.map((mediaRequest) => (
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<Card
|
||||
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${mediaRequest.type} mediaRequests-list-item-${mediaRequest.status}`}
|
||||
key={mediaRequest.id}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Card, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
@@ -27,14 +28,11 @@ import classes from "./component.module.css";
|
||||
export default function MediaServerWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
itemId,
|
||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||
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) => (
|
||||
<Card
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
@@ -170,12 +152,12 @@ export default function MediaServerWidget({
|
||||
radius="2.5cqmin"
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="2cqmin" display="flex">
|
||||
<Tooltip label={user.appName}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size="12.5cqmin"
|
||||
src={user.avatar}
|
||||
bd={`0.5cqmin solid ${user.appKind === "overseerr" ? "#ECB000" : "#6677CC"}`}
|
||||
bd={`0.5cqmin solid ${user.integration.kind === "overseerr" ? "#ECB000" : "#6677CC"}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Stack className="mediaRequests-stats-users-user-infos" gap="2cqmin">
|
||||
|
||||
1
packages/widgets/src/modals/index.ts
Normal file
1
packages/widgets/src/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./widget-edit-modal";
|
||||
@@ -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<TType> {
|
||||
@@ -25,7 +23,7 @@ interface MultiSelectInput<TOptions extends SelectOption[]>
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier>
|
||||
export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier>
|
||||
extends Omit<CommonInput<TOptionValue[]>, "withDescription"> {
|
||||
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
|
||||
ItemComponent: (props: {
|
||||
@@ -188,15 +186,3 @@ export type OptionsBuilderResult = ReturnType<OptionsBuilder>;
|
||||
export const optionsBuilder = {
|
||||
from: createOptions,
|
||||
};
|
||||
|
||||
export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record<string, unknown> = {}) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const options = definition.options as Record<string, WidgetOptionDefinition>;
|
||||
return objectEntries(options).reduce(
|
||||
(prev, [key, value]) => ({
|
||||
...prev,
|
||||
[key]: currentValue[key] ?? value.defaultValue,
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 <InnerComponent options={options} integrationId={integrationId} isEditMode={isEditMode} />;
|
||||
}
|
||||
|
||||
type InnerComponentProps = Pick<WidgetComponentProps<"smartHome-entityState">, "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 (
|
||||
<UnstyledButton
|
||||
@@ -66,11 +73,11 @@ export default function SmartHomeEntityStateWidget({
|
||||
{options.displayName}
|
||||
</Text>
|
||||
<Text ta="center">
|
||||
{lastState?.state}
|
||||
{entityState}
|
||||
{attribute}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user