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 { showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||||
import {
|
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
loadWidgetDynamic,
|
|
||||||
reduceWidgetOptionsWithDefaultValues,
|
|
||||||
WidgetEditModal,
|
|
||||||
widgetImports,
|
|
||||||
} from "@homarr/widgets";
|
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
import type { Dimensions } from "./_dimension-modal";
|
import type { Dimensions } from "./_dimension-modal";
|
||||||
import { PreviewDimensionsModal } 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 { clientApi } from "@homarr/api/client";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
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 type { Item } from "~/app/[locale]/boards/_types";
|
||||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
|
|||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
|
import { createHeadersCallbackForSource } from "@homarr/api/client";
|
||||||
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
@@ -25,11 +26,7 @@ export const serverFetchApi = createTRPCClient<AppRouter>({
|
|||||||
httpLink({
|
httpLink({
|
||||||
url: `http://${process.env.HOSTNAME ?? "localhost"}:3000/api/trpc`,
|
url: `http://${process.env.HOSTNAME ?? "localhost"}:3000/api/trpc`,
|
||||||
transformer: SuperJSON,
|
transformer: SuperJSON,
|
||||||
headers() {
|
headers: createHeadersCallbackForSource("server-fetch"),
|
||||||
const headers = new Headers();
|
|
||||||
headers.set("x-trpc-source", "server-fetch");
|
|
||||||
return headers;
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"main": "./src/main.ts",
|
"main": "./src/main.ts",
|
||||||
"types": "./src/main.ts",
|
"types": "./src/main.ts",
|
||||||
"scripts": {
|
"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",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"main": "./src/main.ts",
|
"main": "./src/main.ts",
|
||||||
"types": "./src/main.ts",
|
"types": "./src/main.ts",
|
||||||
"scripts": {
|
"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",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
|
"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:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
|
||||||
"lint:ws": "pnpm dlx sherif@latest",
|
"lint:ws": "pnpm dlx sherif@latest",
|
||||||
|
"package:new": "turbo gen init",
|
||||||
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
|
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
|
||||||
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
|
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
|
||||||
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
|
"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/old-schema": "workspace:^0.1.0",
|
||||||
"@homarr/ping": "workspace:^0.1.0",
|
"@homarr/ping": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "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/server-settings": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@trpc/client": "next",
|
"@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
|
* 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
|
* @param action action to perform
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
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";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const calendarRouter = createTRPCRouter({
|
export const calendarRouter = createTRPCRouter({
|
||||||
findAllEvents: publicProcedure
|
findAllEvents: publicProcedure
|
||||||
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
.input(z.object({ year: z.number(), month: z.number(), releaseType: z.array(z.enum(radarrReleaseTypes)) }))
|
||||||
.query(async ({ ctx }) => {
|
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||||
const result = await Promise.all(
|
.query(async ({ ctx, input }) => {
|
||||||
ctx.integrations.flatMap(async (integration) => {
|
const results = await Promise.all(
|
||||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
ctx.integrations.map(async (integration) => {
|
||||||
return await cache.getAsync();
|
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 { Modify } from "@homarr/common/types";
|
||||||
import type { Integration } from "@homarr/db/schema/sqlite";
|
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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import { integrationCreator } from "@homarr/integrations";
|
||||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||||
import { controlsInputSchema } from "@homarr/integrations/types";
|
import { controlsInputSchema } from "@homarr/integrations/types";
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||||
import { z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const dnsHoleRouter = createTRPCRouter({
|
export const dnsHoleRouter = createTRPCRouter({
|
||||||
summary: publicProcedure
|
summary: publicProcedure
|
||||||
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
|
|
||||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||||
.query(async ({ input: { widgetKind }, ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind, integration.id);
|
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||||
const { data: summary, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integration,
|
integration: {
|
||||||
timestamp,
|
id: integration.id,
|
||||||
summary,
|
name: integration.name,
|
||||||
|
kind: integration.kind,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
},
|
||||||
|
summary: data,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -34,22 +36,19 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
subscribeToSummary: publicProcedure
|
subscribeToSummary: publicProcedure
|
||||||
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
|
|
||||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||||
.subscription(({ input: { widgetKind }, ctx }) => {
|
.subscription(({ ctx }) => {
|
||||||
return observable<{
|
return observable<{
|
||||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
|
integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
|
||||||
timestamp: Date;
|
|
||||||
summary: DnsHoleSummary;
|
summary: DnsHoleSummary;
|
||||||
}>((emit) => {
|
}>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
for (const integrationWithSecrets of ctx.integrations) {
|
for (const integrationWithSecrets of ctx.integrations) {
|
||||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||||
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind as WidgetKind, integration.id);
|
const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {});
|
||||||
const unsubscribe = channel.subscribe((summary) => {
|
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||||
emit.next({
|
emit.next({
|
||||||
integration,
|
integration,
|
||||||
timestamp: new Date(),
|
|
||||||
summary,
|
summary,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -68,6 +67,12 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx: { integration } }) => {
|
.mutation(async ({ ctx: { integration } }) => {
|
||||||
const client = integrationCreator(integration);
|
const client = integrationCreator(integration);
|
||||||
await client.enableAsync();
|
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
|
disable: publicProcedure
|
||||||
@@ -76,5 +81,11 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const client = integrationCreator(integration);
|
const client = integrationCreator(integration);
|
||||||
await client.disableAsync(input.duration);
|
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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||||
import { downloadClientItemSchema, integrationCreator } 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 { z } from "@homarr/validation";
|
||||||
|
|
||||||
import type { IntegrationAction } from "../../middlewares/integration";
|
import type { IntegrationAction } from "../../middlewares/integration";
|
||||||
@@ -21,12 +21,18 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
.unstable_concat(createDownloadClientIntegrationMiddleware("query"))
|
.unstable_concat(createDownloadClientIntegrationMiddleware("query"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
|
const innerHandler = downloadClientRequestHandler.handler(integration, {});
|
||||||
const { data, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
|
|
||||||
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integration,
|
integration: {
|
||||||
timestamp,
|
id: integration.id,
|
||||||
|
name: integration.name,
|
||||||
|
kind: integration.kind,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
},
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -37,17 +43,15 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
.subscription(({ ctx }) => {
|
.subscription(({ ctx }) => {
|
||||||
return observable<{
|
return observable<{
|
||||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
||||||
timestamp: Date;
|
|
||||||
data: DownloadClientJobsAndStatus;
|
data: DownloadClientJobsAndStatus;
|
||||||
}>((emit) => {
|
}>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
for (const integrationWithSecrets of ctx.integrations) {
|
for (const integrationWithSecrets of ctx.integrations) {
|
||||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||||
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
|
const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {});
|
||||||
const unsubscribe = channel.subscribe((data) => {
|
const unsubscribe = innerHandler.subscribe((data) => {
|
||||||
emit.next({
|
emit.next({
|
||||||
integration,
|
integration,
|
||||||
timestamp: new Date(),
|
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
|
|
||||||
import type { HealthMonitoring } from "@homarr/integrations";
|
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 { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
@@ -12,14 +12,14 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
|
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
||||||
const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
healthInfo,
|
healthInfo: data,
|
||||||
timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -31,8 +31,8 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
|
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
for (const integration of ctx.integrations) {
|
for (const integration of ctx.integrations) {
|
||||||
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
|
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
||||||
const unsubscribe = channel.subscribe((healthInfo) => {
|
const unsubscribe = innerHandler.subscribe((healthInfo) => {
|
||||||
emit.next({
|
emit.next({
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
healthInfo,
|
healthInfo,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
|||||||
import { integrationCreator } from "@homarr/integrations";
|
import { integrationCreator } from "@homarr/integrations";
|
||||||
import type { Indexer } from "@homarr/integrations/types";
|
import type { Indexer } from "@homarr/integrations/types";
|
||||||
import { logger } from "@homarr/log";
|
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 type { IntegrationAction } from "../../middlewares/integration";
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
@@ -20,14 +20,8 @@ export const indexerManagerRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const client = integrationCreator(integration);
|
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
|
||||||
const indexers = await client.getIndexersAsync().catch((err) => {
|
const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
logger.error("indexer-manager router - ", err);
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: `Failed to fetch indexers for ${integration.name} (${integration.id})`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
@@ -43,11 +37,11 @@ export const indexerManagerRouter = createTRPCRouter({
|
|||||||
.subscription(({ ctx }) => {
|
.subscription(({ ctx }) => {
|
||||||
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
|
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
for (const integration of ctx.integrations) {
|
for (const integrationWithSecrets of ctx.integrations) {
|
||||||
const channel = createItemAndIntegrationChannel<Indexer[]>("indexerManager", integration.id);
|
const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {});
|
||||||
const unsubscribe = channel.subscribe((indexers) => {
|
const unsubscribe = innerHandler.subscribe((indexers) => {
|
||||||
emit.next({
|
emit.next({
|
||||||
integrationId: integration.id,
|
integrationId: integrationWithSecrets.id,
|
||||||
indexers,
|
indexers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -60,7 +54,6 @@ export const indexerManagerRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
testAllIndexers: publicProcedure
|
testAllIndexers: publicProcedure
|
||||||
.unstable_concat(createIndexerManagerIntegrationMiddleware("interact"))
|
.unstable_concat(createIndexerManagerIntegrationMiddleware("interact"))
|
||||||
.mutation(async ({ ctx }) => {
|
.mutation(async ({ ctx }) => {
|
||||||
|
|||||||
@@ -1,53 +1,110 @@
|
|||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
|
import { integrationCreator, MediaRequestStatus } from "@homarr/integrations";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
import type { MediaRequest } from "@homarr/integrations/types";
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||||
|
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import {
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
createManyIntegrationOfOneItemMiddleware,
|
|
||||||
createOneIntegrationMiddleware,
|
|
||||||
} from "../../middlewares/integration";
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const mediaRequestsRouter = createTRPCRouter({
|
export const mediaRequestsRouter = createTRPCRouter({
|
||||||
getLatestRequests: publicProcedure
|
getLatestRequests: publicProcedure
|
||||||
.unstable_concat(
|
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||||
createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")),
|
.query(async ({ ctx }) => {
|
||||||
)
|
const results = await Promise.all(
|
||||||
.query(async ({ input }) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
return await Promise.all(
|
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
||||||
input.integrationIds.map(async (integrationId) => {
|
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
const channel = createItemAndIntegrationChannel<MediaRequestList>("mediaRequests-requestList", integrationId);
|
return {
|
||||||
return await channel.getAsync();
|
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
|
getStats: publicProcedure
|
||||||
.unstable_concat(
|
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||||
createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")),
|
.query(async ({ ctx }) => {
|
||||||
)
|
const results = await Promise.all(
|
||||||
.query(async ({ input }) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
return await Promise.all(
|
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
|
||||||
input.integrationIds.map(async (integrationId) => {
|
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
const channel = createItemAndIntegrationChannel<MediaRequestStats>(
|
return {
|
||||||
"mediaRequests-requestStats",
|
integration: {
|
||||||
integrationId,
|
id: integration.id,
|
||||||
);
|
name: integration.name,
|
||||||
return await channel.getAsync();
|
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
|
answerRequest: protectedProcedure
|
||||||
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
|
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||||
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
|
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const integrationInstance = integrationCreator(integration);
|
const integrationInstance = integrationCreator(integration);
|
||||||
|
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
||||||
|
|
||||||
if (input.answer === "approve") {
|
if (input.answer === "approve") {
|
||||||
await integrationInstance.approveRequestAsync(input.requestId);
|
await integrationInstance.approveRequestAsync(input.requestId);
|
||||||
|
await innerHandler.invalidateAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await integrationInstance.declineRequestAsync(input.requestId);
|
await integrationInstance.declineRequestAsync(input.requestId);
|
||||||
|
await innerHandler.invalidateAsync();
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
|
|||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { StreamSession } from "@homarr/integrations";
|
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 type { IntegrationAction } from "../../middlewares/integration";
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
@@ -17,11 +17,11 @@ export const mediaServerRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id);
|
const innerHandler = mediaServerRequestHandler.handler(integration, {});
|
||||||
const data = await channel.getAsync();
|
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
sessions: data?.data ?? [],
|
sessions: data,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -32,8 +32,9 @@ export const mediaServerRouter = createTRPCRouter({
|
|||||||
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
|
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
for (const integration of ctx.integrations) {
|
for (const integration of ctx.integrations) {
|
||||||
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id);
|
const innerHandler = mediaServerRequestHandler.handler(integration, {});
|
||||||
const unsubscribe = channel.subscribe((sessions) => {
|
|
||||||
|
const unsubscribe = innerHandler.subscribe((sessions) => {
|
||||||
emit.next({
|
emit.next({
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
data: sessions,
|
data: sessions,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
|
|||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import { integrationCreator } from "@homarr/integrations";
|
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 { z } from "@homarr/validation";
|
||||||
|
|
||||||
import type { IntegrationAction } from "../../middlewares/integration";
|
import type { IntegrationAction } from "../../middlewares/integration";
|
||||||
@@ -13,29 +13,45 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
|
|||||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
|
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
|
||||||
|
|
||||||
export const smartHomeRouter = createTRPCRouter({
|
export const smartHomeRouter = createTRPCRouter({
|
||||||
subscribeEntityState: publicProcedure.input(z.object({ entityId: z.string() })).subscription(({ input }) => {
|
entityState: publicProcedure
|
||||||
return observable<{
|
.input(z.object({ entityId: z.string() }))
|
||||||
entityId: string;
|
.unstable_concat(createSmartHomeIntegrationMiddleware("query"))
|
||||||
state: string;
|
.query(async ({ ctx: { integration }, input }) => {
|
||||||
}>((emit) => {
|
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||||
const unsubscribe = homeAssistantEntityState.subscribe((message) => {
|
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
if (message.entityId !== input.entityId) {
|
return data;
|
||||||
return;
|
}),
|
||||||
}
|
subscribeEntityState: publicProcedure
|
||||||
emit.next(message);
|
.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 () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
switchEntity: publicProcedure
|
switchEntity: publicProcedure
|
||||||
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||||
.input(z.object({ entityId: z.string() }))
|
.input(z.object({ entityId: z.string() }))
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const client = integrationCreator(integration);
|
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
|
executeAutomation: publicProcedure
|
||||||
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||||
|
|||||||
@@ -21,3 +21,20 @@ export const useTimeAgo = (timestamp: Date) => {
|
|||||||
|
|
||||||
return timeAgo;
|
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)[] {
|
export function objectKeys<O extends object>(obj: O): (keyof O)[] {
|
||||||
return Object.keys(obj) as (keyof O)[];
|
return Object.keys(obj) as (keyof O)[];
|
||||||
}
|
}
|
||||||
@@ -7,3 +9,7 @@ type Entries<T> = {
|
|||||||
}[keyof T][];
|
}[keyof T][];
|
||||||
|
|
||||||
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Entries<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> = {
|
export type RemoveReadonly<T> = {
|
||||||
-readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P];
|
-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/log": "workspace:^0.1.0",
|
||||||
"@homarr/ping": "workspace:^0.1.0",
|
"@homarr/ping": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "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/server-settings": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "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 { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||||
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
||||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
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 { mediaServerJob } from "./jobs/integrations/media-server";
|
||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import type { RssFeed } from "./jobs/rss-feeds";
|
import type { RssFeed } from "./jobs/rss-feeds";
|
||||||
@@ -23,7 +23,8 @@ export const jobGroup = createCronJobGroup({
|
|||||||
mediaOrganizer: mediaOrganizerJob,
|
mediaOrganizer: mediaOrganizerJob,
|
||||||
downloads: downloadsJob,
|
downloads: downloadsJob,
|
||||||
dnsHole: dnsHoleJob,
|
dnsHole: dnsHoleJob,
|
||||||
mediaRequests: mediaRequestsJob,
|
mediaRequestStats: mediaRequestStatsJob,
|
||||||
|
mediaRequestList: mediaRequestListJob,
|
||||||
rssFeeds: rssFeedsJob,
|
rssFeeds: rssFeedsJob,
|
||||||
indexerManager: indexerManagerJob,
|
indexerManager: indexerManagerJob,
|
||||||
healthMonitoring: healthMonitoringJob,
|
healthMonitoring: healthMonitoringJob,
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
|
||||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
|
||||||
import { logger } from "@homarr/log";
|
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(async () => {
|
export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(
|
||||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
createRequestIntegrationJobHandler(dnsHoleRequestHandler.handler, {
|
||||||
kinds: ["dnsHoleSummary", "dnsHoleControls"],
|
widgetKinds: ["dnsHoleSummary", "dnsHoleControls"],
|
||||||
});
|
getInput: {
|
||||||
|
dnsHoleSummary: () => ({}),
|
||||||
for (const itemForIntegration of itemsForIntegration) {
|
dnsHoleControls: () => ({}),
|
||||||
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}"`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
|
||||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(async () => {
|
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(
|
||||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, {
|
||||||
kinds: ["downloads"],
|
widgetKinds: ["downloads"],
|
||||||
});
|
getInput: {
|
||||||
|
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}"`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(async () => {
|
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
|
||||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, {
|
||||||
kinds: ["healthMonitoring"],
|
widgetKinds: ["healthMonitoring"],
|
||||||
});
|
getInput: {
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,42 +1,16 @@
|
|||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
|
||||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
|
||||||
import { logger } from "@homarr/log";
|
|
||||||
import { homeAssistantEntityState } from "@homarr/redis";
|
|
||||||
|
|
||||||
// This import is done that way to avoid circular dependencies.
|
|
||||||
import type { WidgetComponentProps } from "../../../../widgets";
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(async () => {
|
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(
|
||||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
createRequestIntegrationJobHandler(smartHomeEntityStateRequestHandler.handler, {
|
||||||
kinds: ["smartHome-entityState"],
|
widgetKinds: ["smartHome-entityState"],
|
||||||
});
|
getInput: {
|
||||||
|
"smartHome-entityState": (options) => ({
|
||||||
for (const itemForIntegration of itemsForIntegration) {
|
entityId: options.entityId,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => {
|
export const indexerManagerJob = createCronJob("indexerManager", EVERY_5_MINUTES).withCallback(
|
||||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
createRequestIntegrationJobHandler(indexerManagerRequestHandler.handler, {
|
||||||
kinds: ["indexerManager"],
|
widgetKinds: ["indexerManager"],
|
||||||
});
|
getInput: {
|
||||||
|
indexerManager: () => ({}),
|
||||||
for (const itemForIntegration of itemsForIntegration) {
|
},
|
||||||
for (const { integration } of itemForIntegration.integrations) {
|
}),
|
||||||
const integrationInstance = integrationCreatorFromSecrets(integration);
|
);
|
||||||
await integrationInstance.getIndexersAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,36 +1,35 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
|
||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
|
||||||
|
|
||||||
// This import is done that way to avoid circular dependencies.
|
|
||||||
import type { WidgetComponentProps } from "../../../../widgets";
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => {
|
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(
|
||||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
createRequestIntegrationJobHandler(calendarMonthRequestHandler.handler, {
|
||||||
kinds: ["calendar"],
|
widgetKinds: ["calendar"],
|
||||||
});
|
getInput: {
|
||||||
|
// Request handler will run for all specified months
|
||||||
|
calendar: (options) => {
|
||||||
|
const inputs = [];
|
||||||
|
|
||||||
for (const itemForIntegration of itemsForIntegration) {
|
const startOffset = -Number(options.filterPastMonths);
|
||||||
for (const { integration } of itemForIntegration.integrations) {
|
const endOffset = Number(options.filterFutureMonths);
|
||||||
const options = SuperJSON.parse<WidgetComponentProps<"calendar">["options"]>(itemForIntegration.options);
|
|
||||||
|
|
||||||
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
|
for (let offsetMonths = startOffset; offsetMonths <= endOffset; offsetMonths++) {
|
||||||
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
|
const year = dayjs().subtract(offsetMonths, "months").year();
|
||||||
|
const month = dayjs().subtract(offsetMonths, "months").month();
|
||||||
|
|
||||||
//Asserting the integration kind until all of them get implemented
|
inputs.push({
|
||||||
const integrationInstance = integrationCreatorFromSecrets(integration);
|
year,
|
||||||
|
month,
|
||||||
|
releaseType: options.releaseType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const events = await integrationInstance.getCalendarEventsAsync(start, end);
|
return inputs;
|
||||||
|
},
|
||||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
},
|
||||||
await cache.setAsync(events);
|
}),
|
||||||
}
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,42 +1,24 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||||
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
|
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).withCallback(async () => {
|
export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MINUTE).withCallback(
|
||||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
createRequestIntegrationJobHandler(mediaRequestStatsRequestHandler.handler, {
|
||||||
kinds: ["mediaRequests-requestList", "mediaRequests-requestStats"],
|
widgetKinds: ["mediaRequests-requestStats"],
|
||||||
});
|
getInput: {
|
||||||
|
"mediaRequests-requestStats": () => ({}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
for (const itemForIntegration of itemsForIntegration) {
|
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
|
||||||
for (const { integration } of itemForIntegration.integrations) {
|
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
|
||||||
const requestsIntegration = integrationCreatorFromSecrets(integration);
|
widgetKinds: ["mediaRequests-requestList"],
|
||||||
|
getInput: {
|
||||||
const mediaRequests = await requestsIntegration.getRequestsAsync();
|
"mediaRequests-requestList": () => ({}),
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(async () => {
|
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(
|
||||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, {
|
||||||
kinds: ["mediaServer"],
|
widgetKinds: ["mediaServer"],
|
||||||
});
|
getInput: {
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
|
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
|
||||||
type ReleaseType = (typeof radarrReleaseTypes)[number];
|
export type RadarrReleaseType = (typeof radarrReleaseTypes)[number];
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
name: string;
|
name: string;
|
||||||
subName: string;
|
subName: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
dates?: { type: ReleaseType; date: Date }[];
|
dates?: { type: RadarrReleaseType; date: Date }[];
|
||||||
description?: string;
|
description?: string;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
mediaInformation?: {
|
mediaInformation?: {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const downloadClientItemSchema = z.object({
|
|||||||
export type DownloadClientItem = z.infer<typeof downloadClientItemSchema>;
|
export type DownloadClientItem = z.infer<typeof downloadClientItemSchema>;
|
||||||
|
|
||||||
export type ExtendedDownloadClientItem = {
|
export type ExtendedDownloadClientItem = {
|
||||||
integration: Integration;
|
integration: Pick<Integration, "id" | "name" | "kind">;
|
||||||
received: number;
|
received: number;
|
||||||
ratio?: number;
|
ratio?: number;
|
||||||
actions?: {
|
actions?: {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface DownloadClientStatus {
|
|||||||
type: "usenet" | "torrent";
|
type: "usenet" | "torrent";
|
||||||
}
|
}
|
||||||
export interface ExtendedClientStatus {
|
export interface ExtendedClientStatus {
|
||||||
integration: Integration;
|
integration: Pick<Integration, "id" | "name" | "kind"> & { updatedAt: Date };
|
||||||
interact: boolean;
|
interact: boolean;
|
||||||
status?: {
|
status?: {
|
||||||
/** To derive from current items */
|
/** To derive from current items */
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ export interface RequestUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaRequestStats {
|
export interface MediaRequestStats {
|
||||||
integration: {
|
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
stats: RequestStats;
|
stats: RequestStats;
|
||||||
users: RequestUser[];
|
users: RequestUser[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { logger } from "@homarr/log";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
@@ -125,20 +126,38 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async approveRequestAsync(requestId: number): Promise<void> {
|
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`, {
|
await fetch(`${this.integration.url}/api/v1/request/${requestId}/approve`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
"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> {
|
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`, {
|
await fetch(`${this.integration.url}/api/v1/request/${requestId}/decline`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
"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 "./interfaces/media-requests/media-request";
|
||||||
export * from "./pi-hole/pi-hole-types";
|
export * from "./pi-hole/pi-hole-types";
|
||||||
export * from "./base/searchable-integration";
|
export * from "./base/searchable-integration";
|
||||||
|
export * from "./homeassistant/homeassistant-types";
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
|
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 exampleChannel = createSubPubChannel<{ message: string }>("example");
|
||||||
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
|
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { hashObjectBase64 } from "@homarr/common";
|
||||||
import { createId } from "@homarr/db";
|
import { createId } from "@homarr/db";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
@@ -172,11 +173,21 @@ export const createItemAndIntegrationChannel = <TData>(kind: WidgetKind, integra
|
|||||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
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) => {
|
export const createItemChannel = <TData>(itemId: string) => {
|
||||||
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
|
export const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
|
||||||
return {
|
return {
|
||||||
subscribe: (callback: (data: TData) => void) => {
|
subscribe: (callback: (data: TData) => void) => {
|
||||||
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
||||||
@@ -196,6 +207,7 @@ const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
|
|||||||
|
|
||||||
return superjson.parse<{ data: TData; timestamp: Date }>(data);
|
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": {
|
"downloads": {
|
||||||
"label": "Downloads"
|
"label": "Downloads"
|
||||||
},
|
},
|
||||||
"mediaRequests": {
|
"mediaRequestStats": {
|
||||||
"label": "Media Requests"
|
"label": "Media Request Stats"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "Media Request List"
|
||||||
},
|
},
|
||||||
"rssFeeds": {
|
"rssFeeds": {
|
||||||
"label": "RSS feeds"
|
"label": "RSS feeds"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
"./errors": "./src/errors/component.tsx"
|
"./errors": "./src/errors/component.tsx",
|
||||||
|
"./modals": "./src/modals/index.ts"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"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 { IconClock, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
import { AppSelectModal } from "./app-select-modal";
|
import { BookmarkAddButton } from "./add-button";
|
||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||||
icon: IconClock,
|
icon: IconClock,
|
||||||
@@ -42,16 +40,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
AddButton({ addItem, values }) {
|
AddButton: BookmarkAddButton,
|
||||||
const { openModal } = useModalAction(AppSelectModal);
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
|
|
||||||
{t("widget.bookmarks.option.items.add")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
uniqueIdentifier: (item) => item.id,
|
uniqueIdentifier: (item) => item.id,
|
||||||
useData: (initialIds) => {
|
useData: (initialIds) => {
|
||||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(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 { CalendarDay } from "./calender-day";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
export default function CalendarWidget({
|
export default function CalendarWidget({ isEditMode, integrationIds, options }: WidgetComponentProps<"calendar">) {
|
||||||
isEditMode,
|
const [month, setMonth] = useState(new Date());
|
||||||
integrationIds,
|
|
||||||
itemId,
|
|
||||||
options,
|
|
||||||
}: WidgetComponentProps<"calendar">) {
|
|
||||||
const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(
|
const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
itemId: itemId!,
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
|
month: month.getMonth(),
|
||||||
|
year: month.getFullYear(),
|
||||||
|
releaseType: options.releaseType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
@@ -31,7 +28,6 @@ export default function CalendarWidget({
|
|||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const [month, setMonth] = useState(new Date());
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const locale = params.locale as string;
|
const locale = params.locale as string;
|
||||||
const [firstDayOfWeek] = clientApi.user.getFirstDayOfWeekForUserOrDefault.useSuspenseQuery();
|
const [firstDayOfWeek] = clientApi.user.getFirstDayOfWeekForUserOrDefault.useSuspenseQuery();
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
|
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||||
|
import { useIntegrationConnected } from "@homarr/common";
|
||||||
import { integrationDefs } from "@homarr/definitions";
|
import { integrationDefs } from "@homarr/definitions";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { NoIntegrationSelectedError } from "../../errors";
|
import { NoIntegrationSelectedError } from "../../errors";
|
||||||
import TimerModal from "./TimerModal";
|
import TimerModal from "./TimerModal";
|
||||||
@@ -47,7 +47,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
|
|
||||||
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
widgetKind: "dnsHoleControls",
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,21 +60,27 @@ export default function DnsHoleControlsWidget({
|
|||||||
// Subscribe to summary updates
|
// Subscribe to summary updates
|
||||||
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
|
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
|
||||||
{
|
{
|
||||||
widgetKind,
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onData: (data) => {
|
onData: (data) => {
|
||||||
utils.widget.dnsHole.summary.setData(
|
utils.widget.dnsHole.summary.setData(
|
||||||
{
|
{
|
||||||
widgetKind: "dnsHoleControls",
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
if (!prevData) return undefined;
|
if (!prevData) return undefined;
|
||||||
|
|
||||||
const newData = prevData.map((summary) =>
|
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;
|
return newData;
|
||||||
@@ -90,14 +95,13 @@ export default function DnsHoleControlsWidget({
|
|||||||
onSettled: (_, error, { integrationId }) => {
|
onSettled: (_, error, { integrationId }) => {
|
||||||
utils.widget.dnsHole.summary.setData(
|
utils.widget.dnsHole.summary.setData(
|
||||||
{
|
{
|
||||||
widgetKind: "dnsHoleControls",
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
if (!prevData) return [];
|
if (!prevData) return [];
|
||||||
|
|
||||||
return prevData.map((item) =>
|
return prevData.map((item) =>
|
||||||
item.integration.id === integrationId && item.summary
|
item.integration.id === integrationId
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
summary: {
|
summary: {
|
||||||
@@ -115,14 +119,13 @@ export default function DnsHoleControlsWidget({
|
|||||||
onSettled: (_, error, { integrationId }) => {
|
onSettled: (_, error, { integrationId }) => {
|
||||||
utils.widget.dnsHole.summary.setData(
|
utils.widget.dnsHole.summary.setData(
|
||||||
{
|
{
|
||||||
widgetKind: "dnsHoleControls",
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
if (!prevData) return [];
|
if (!prevData) return [];
|
||||||
|
|
||||||
return prevData.map((item) =>
|
return prevData.map((item) =>
|
||||||
item.integration.id === integrationId && item.summary
|
item.integration.id === integrationId
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
summary: {
|
summary: {
|
||||||
@@ -138,17 +141,16 @@ export default function DnsHoleControlsWidget({
|
|||||||
});
|
});
|
||||||
const toggleDns = (integrationId: string) => {
|
const toggleDns = (integrationId: string) => {
|
||||||
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
|
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
|
||||||
if (!integrationStatus?.summary?.status) return;
|
if (!integrationStatus?.summary.status) return;
|
||||||
utils.widget.dnsHole.summary.setData(
|
utils.widget.dnsHole.summary.setData(
|
||||||
{
|
{
|
||||||
widgetKind: "dnsHoleControls",
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
if (!prevData) return [];
|
if (!prevData) return [];
|
||||||
|
|
||||||
return prevData.map((item) =>
|
return prevData.map((item) =>
|
||||||
item.integration.id === integrationId && item.summary
|
item.integration.id === integrationId
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
summary: {
|
summary: {
|
||||||
@@ -170,7 +172,7 @@ export default function DnsHoleControlsWidget({
|
|||||||
// make lists of enabled and disabled interactable integrations (with permissions, not disconnected and not processing)
|
// make lists of enabled and disabled interactable integrations (with permissions, not disconnected and not processing)
|
||||||
const integrationsSummaries = summaries.reduce(
|
const integrationsSummaries = summaries.reduce(
|
||||||
(acc, { summary, integration: { id } }) =>
|
(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[] },
|
{ enabled: [] as string[], disabled: [] as string[] },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -310,9 +312,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
|||||||
open,
|
open,
|
||||||
t,
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
// Independently determine connection status, current state and permissions
|
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
|
||||||
const isConnected = data.summary !== null && Math.abs(dayjs(data.timestamp).diff()) < 30000;
|
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
|
||||||
const isEnabled = data.summary?.status ? data.summary.status === "enabled" : undefined;
|
|
||||||
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
|
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
|
||||||
// Use all factors to infer the state of the action buttons
|
// Use all factors to infer the state of the action buttons
|
||||||
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
|
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
|
||||||
@@ -355,7 +356,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
|||||||
lts="0.1cqmin"
|
lts="0.1cqmin"
|
||||||
color="var(--background-color)"
|
color="var(--background-color)"
|
||||||
c="var(--mantine-color-text)"
|
c="var(--mantine-color-text)"
|
||||||
styles={{ section: { marginInlineEnd: "2.5cqmin" } }}
|
styles={{ section: { marginInlineEnd: "2.5cqmin" }, root: { cursor: "inherit" } }}
|
||||||
leftSection={
|
leftSection={
|
||||||
isConnected && (
|
isConnected && (
|
||||||
<IconCircleFilled
|
<IconCircleFilled
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { BoxProps } from "@mantine/core";
|
|||||||
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
|
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { formatNumber } from "@homarr/common";
|
import { formatNumber } from "@homarr/common";
|
||||||
@@ -16,14 +15,13 @@ import { translateIfNecessary } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||||
import { NoIntegrationSelectedError } from "../../errors";
|
import { NoIntegrationSelectedError } from "../../errors";
|
||||||
|
|
||||||
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
|
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
|
||||||
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
widgetKind,
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -39,14 +37,12 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
|
|||||||
|
|
||||||
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
|
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
|
||||||
{
|
{
|
||||||
widgetKind,
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onData: (data) => {
|
onData: (data) => {
|
||||||
utils.widget.dnsHole.summary.setData(
|
utils.widget.dnsHole.summary.setData(
|
||||||
{
|
{
|
||||||
widgetKind,
|
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
@@ -64,14 +60,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = useMemo(
|
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||||
() =>
|
|
||||||
summaries
|
|
||||||
.filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000)
|
|
||||||
.flatMap(({ summary }) => summary)
|
|
||||||
.filter((summary) => summary !== null),
|
|
||||||
[summaries],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (integrationIds.length === 0) {
|
if (integrationIds.length === 0) {
|
||||||
throw new NoIntegrationSelectedError();
|
throw new NoIntegrationSelectedError();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure, useTimeout } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import type { IconProps } from "@tabler/icons-react";
|
import type { IconProps } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
@@ -39,13 +39,9 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/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 { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type {
|
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||||
DownloadClientJobsAndStatus,
|
|
||||||
ExtendedClientStatus,
|
|
||||||
ExtendedDownloadClientItem,
|
|
||||||
} from "@homarr/integrations";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
@@ -82,8 +78,6 @@ const standardIconStyle: IconProps["style"] = {
|
|||||||
width: "var(--icon-size)",
|
width: "var(--icon-size)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const invalidateTime = 30000;
|
|
||||||
|
|
||||||
export default function DownloadClientsWidget({
|
export default function DownloadClientsWidget({
|
||||||
isEditMode,
|
isEditMode,
|
||||||
integrationIds,
|
integrationIds,
|
||||||
@@ -103,26 +97,10 @@ export default function DownloadClientsWidget({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: 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();
|
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
|
//Translations
|
||||||
const t = useScopedI18n("widget.downloads");
|
const t = useScopedI18n("widget.downloads");
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
@@ -143,32 +121,19 @@ export default function DownloadClientsWidget({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onData: (data) => {
|
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) => {
|
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => {
|
||||||
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
|
return prevData?.map((item) => {
|
||||||
if (updateIndex >= 0) {
|
if (item.integration.id !== data.integration.id) return item;
|
||||||
//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 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
|
currentItems
|
||||||
//Insure it is only using selected integrations
|
//Insure it is only using selected integrations
|
||||||
.filter(({ integration }) => integrationIds.includes(integration.id))
|
.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
|
//Construct normalized items list
|
||||||
.flatMap((pair) =>
|
.flatMap((pair) =>
|
||||||
//Apply user white/black list
|
//Apply user white/black list
|
||||||
@@ -255,7 +210,6 @@ export default function DownloadClientsWidget({
|
|||||||
.filter(({ integration }) => integrationIds.includes(integration.id))
|
.filter(({ integration }) => integrationIds.includes(integration.id))
|
||||||
.flatMap(({ integration, data }): ExtendedClientStatus => {
|
.flatMap(({ integration, data }): ExtendedClientStatus => {
|
||||||
const interact = integrationsWithInteractions.includes(integration.id);
|
const interact = integrationsWithInteractions.includes(integration.id);
|
||||||
if (!data) return { integration, interact };
|
|
||||||
const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind);
|
const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind);
|
||||||
/** Derived from current items */
|
/** Derived from current items */
|
||||||
const { totalUp, totalDown } = data.items
|
const { totalUp, totalDown } = data.items
|
||||||
@@ -821,12 +775,7 @@ const ClientsControl = ({ clients, style }: ClientsControlProps) => {
|
|||||||
<Group gap="var(--space-size)" style={style}>
|
<Group gap="var(--space-size)" style={style}>
|
||||||
<AvatarGroup spacing="calc(var(--space-size)*2)">
|
<AvatarGroup spacing="calc(var(--space-size)*2)">
|
||||||
{clients.map((client) => (
|
{clients.map((client) => (
|
||||||
<Avatar
|
<ClientAvatar key={client.integration.id} client={client} />
|
||||||
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)"}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
{someInteract && (
|
{someInteract && (
|
||||||
@@ -939,3 +888,21 @@ const ClientsControl = ({ clients, style }: ClientsControlProps) => {
|
|||||||
</Group>
|
</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 duration from "dayjs/plugin/duration";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { HealthMonitoring } from "@homarr/integrations";
|
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -53,17 +52,6 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
retry: 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);
|
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, healthInfo: data.healthInfo, timestamp: data.timestamp }
|
||||||
: item,
|
: item,
|
||||||
);
|
);
|
||||||
return newData.filter(
|
return newData;
|
||||||
(
|
|
||||||
health,
|
|
||||||
): health is {
|
|
||||||
integrationId: string;
|
|
||||||
integrationName: string;
|
|
||||||
healthInfo: HealthMonitoring;
|
|
||||||
timestamp: Date;
|
|
||||||
} => health.healthInfo !== null,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -102,7 +81,7 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="2.5cqmin" className="health-monitoring">
|
<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 disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
||||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
|
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
|
||||||
return (
|
return (
|
||||||
@@ -211,15 +190,17 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
|
|||||||
)}
|
)}
|
||||||
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
|
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Text
|
{
|
||||||
className="health-monitoring-status-update-time"
|
<Text
|
||||||
c="dimmed"
|
className="health-monitoring-status-update-time"
|
||||||
size="3.5cqmin"
|
c="dimmed"
|
||||||
ta="center"
|
size="3.5cqmin"
|
||||||
mb="2.5cqmin"
|
ta="center"
|
||||||
>
|
mb="2.5cqmin"
|
||||||
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(timestamp).fromNow() })}
|
>
|
||||||
</Text>
|
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
</Card>
|
</Card>
|
||||||
{options.fileSystem &&
|
{options.fileSystem &&
|
||||||
disksData.map((disk) => {
|
disksData.map((disk) => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconBrowserOff } from "@tabler/icons-react";
|
import { IconBrowserOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { Loader } from "next/dynamic";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Center, Loader as UiLoader } from "@mantine/core";
|
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 app from "./app";
|
||||||
import * as bookmarks from "./bookmarks";
|
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 mediaRequestsStats from "./media-requests/stats";
|
||||||
import * as mediaServer from "./media-server";
|
import * as mediaServer from "./media-server";
|
||||||
import * as notebook from "./notebook";
|
import * as notebook from "./notebook";
|
||||||
|
import type { WidgetOptionDefinition } from "./options";
|
||||||
import * as rssFeed from "./rssFeed";
|
import * as rssFeed from "./rssFeed";
|
||||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||||
import * as video from "./video";
|
import * as video from "./video";
|
||||||
import * as weather from "./weather";
|
import * as weather from "./weather";
|
||||||
|
|
||||||
export { reduceWidgetOptionsWithDefaultValues } from "./options";
|
|
||||||
|
|
||||||
export type { WidgetDefinition } from "./definition";
|
export type { WidgetDefinition } from "./definition";
|
||||||
export { WidgetEditModal } from "./modals/widget-edit-modal";
|
|
||||||
export type { WidgetComponentProps };
|
export type { WidgetComponentProps };
|
||||||
|
|
||||||
export const widgetImports = {
|
export const widgetImports = {
|
||||||
@@ -84,3 +83,21 @@ export type inferSupportedIntegrations<TKind extends WidgetKind> = (WidgetImport
|
|||||||
}
|
}
|
||||||
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||||
: string[])[number];
|
: 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 { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -15,14 +16,11 @@ export default function MediaServerWidget({
|
|||||||
integrationIds,
|
integrationIds,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
options,
|
options,
|
||||||
itemId,
|
|
||||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||||
const t = useScopedI18n("widget.mediaRequests-requestList");
|
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||||
const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
|
const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
itemId: itemId!,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
@@ -30,30 +28,39 @@ export default function MediaServerWidget({
|
|||||||
refetchOnReconnect: false,
|
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(
|
const filteredData = prevData.filter(({ integrationId }) => integrationId !== data.integrationId);
|
||||||
() =>
|
const newData = filteredData.concat(
|
||||||
mediaRequests
|
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
|
||||||
.filter((group) => group != null)
|
);
|
||||||
.flatMap((group) => group.data)
|
return newData.sort(({ status: statusA }, { status: statusB }) => {
|
||||||
.flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id })))
|
if (statusA === MediaRequestStatus.PendingApproval) {
|
||||||
.sort(({ status: statusA }, { status: statusB }) => {
|
return -1;
|
||||||
if (statusA === MediaRequestStatus.PendingApproval) {
|
}
|
||||||
return -1;
|
if (statusB === MediaRequestStatus.PendingApproval) {
|
||||||
}
|
return 1;
|
||||||
if (statusB === MediaRequestStatus.PendingApproval) {
|
}
|
||||||
return 1;
|
return 0;
|
||||||
}
|
});
|
||||||
return 0;
|
});
|
||||||
}),
|
},
|
||||||
[mediaRequests],
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
|
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
|
||||||
|
|
||||||
if (integrationIds.length === 0) throw new NoIntegrationSelectedError();
|
if (integrationIds.length === 0) throw new NoIntegrationSelectedError();
|
||||||
|
|
||||||
if (sortedMediaRequests.length === 0) throw new NoIntegrationDataError();
|
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
@@ -62,7 +69,7 @@ export default function MediaServerWidget({
|
|||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
>
|
>
|
||||||
<Stack className="mediaRequests-list-list" gap="2cqmin" p="2cqmin">
|
<Stack className="mediaRequests-list-list" gap="2cqmin" p="2cqmin">
|
||||||
{sortedMediaRequests.map((mediaRequest) => (
|
{mediaRequests.map((mediaRequest) => (
|
||||||
<Card
|
<Card
|
||||||
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${mediaRequest.type} mediaRequests-list-item-${mediaRequest.status}`}
|
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${mediaRequest.type} mediaRequests-list-item-${mediaRequest.status}`}
|
||||||
key={mediaRequest.id}
|
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 { ActionIcon, Avatar, Card, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import type { Icon } from "@tabler/icons-react";
|
import type { Icon } from "@tabler/icons-react";
|
||||||
@@ -27,14 +28,11 @@ import classes from "./component.module.css";
|
|||||||
export default function MediaServerWidget({
|
export default function MediaServerWidget({
|
||||||
integrationIds,
|
integrationIds,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
itemId,
|
|
||||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||||
const t = useScopedI18n("widget.mediaRequests-requestStats");
|
const t = useScopedI18n("widget.mediaRequests-requestStats");
|
||||||
const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
|
const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
integrationIds,
|
integrationIds,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
itemId: itemId!,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
@@ -45,67 +43,51 @@ export default function MediaServerWidget({
|
|||||||
|
|
||||||
const { width, height, ref } = useElementSize();
|
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 (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
|
//Add processing and available
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
name: "approved",
|
name: "approved",
|
||||||
icon: IconThumbUp,
|
icon: IconThumbUp,
|
||||||
number: stats.reduce((count, { approved }) => count + approved, 0),
|
number: requestStats.stats.reduce((count, { approved }) => count + approved, 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pending",
|
name: "pending",
|
||||||
icon: IconHourglass,
|
icon: IconHourglass,
|
||||||
number: stats.reduce((count, { pending }) => count + pending, 0),
|
number: requestStats.stats.reduce((count, { pending }) => count + pending, 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "processing",
|
name: "processing",
|
||||||
icon: IconLoaderQuarter,
|
icon: IconLoaderQuarter,
|
||||||
number: stats.reduce((count, { processing }) => count + processing, 0),
|
number: requestStats.stats.reduce((count, { processing }) => count + processing, 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "declined",
|
name: "declined",
|
||||||
icon: IconThumbDown,
|
icon: IconThumbDown,
|
||||||
number: stats.reduce((count, { declined }) => count + declined, 0),
|
number: requestStats.stats.reduce((count, { declined }) => count + declined, 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "available",
|
name: "available",
|
||||||
icon: IconPlayerPlay,
|
icon: IconPlayerPlay,
|
||||||
number: stats.reduce((count, { available }) => count + available, 0),
|
number: requestStats.stats.reduce((count, { available }) => count + available, 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tv",
|
name: "tv",
|
||||||
icon: IconDeviceTv,
|
icon: IconDeviceTv,
|
||||||
number: stats.reduce((count, { tv }) => count + tv, 0),
|
number: requestStats.stats.reduce((count, { tv }) => count + tv, 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "movie",
|
name: "movie",
|
||||||
icon: IconMovie,
|
icon: IconMovie,
|
||||||
number: stats.reduce((count, { movie }) => count + movie, 0),
|
number: requestStats.stats.reduce((count, { movie }) => count + movie, 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "total",
|
name: "total",
|
||||||
icon: IconReceipt,
|
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 }[];
|
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
|
||||||
|
|
||||||
@@ -156,7 +138,7 @@ export default function MediaServerWidget({
|
|||||||
gap="2cqmin"
|
gap="2cqmin"
|
||||||
style={{ overflow: "hidden" }}
|
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
|
<Card
|
||||||
className={combineClasses(
|
className={combineClasses(
|
||||||
"mediaRequests-stats-users-user-wrapper",
|
"mediaRequests-stats-users-user-wrapper",
|
||||||
@@ -170,12 +152,12 @@ export default function MediaServerWidget({
|
|||||||
radius="2.5cqmin"
|
radius="2.5cqmin"
|
||||||
>
|
>
|
||||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="2cqmin" display="flex">
|
<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
|
<Avatar
|
||||||
className="mediaRequests-stats-users-user-avatar"
|
className="mediaRequests-stats-users-user-avatar"
|
||||||
size="12.5cqmin"
|
size="12.5cqmin"
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
bd={`0.5cqmin solid ${user.appKind === "overseerr" ? "#ECB000" : "#6677CC"}`}
|
bd={`0.5cqmin solid ${user.integration.kind === "overseerr" ? "#ECB000" : "#6677CC"}`}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Stack className="mediaRequests-stats-users-user-infos" gap="2cqmin">
|
<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 { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
|
||||||
import type { ActionIconProps } from "@mantine/core";
|
import type { ActionIconProps } from "@mantine/core";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
|
||||||
import type { ZodType } from "@homarr/validation";
|
import type { ZodType } from "@homarr/validation";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { widgetImports } from ".";
|
|
||||||
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
||||||
|
|
||||||
interface CommonInput<TType> {
|
interface CommonInput<TType> {
|
||||||
@@ -25,7 +23,7 @@ interface MultiSelectInput<TOptions extends SelectOption[]>
|
|||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier>
|
export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier>
|
||||||
extends Omit<CommonInput<TOptionValue[]>, "withDescription"> {
|
extends Omit<CommonInput<TOptionValue[]>, "withDescription"> {
|
||||||
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
|
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
|
||||||
ItemComponent: (props: {
|
ItemComponent: (props: {
|
||||||
@@ -188,15 +186,3 @@ export type OptionsBuilderResult = ReturnType<OptionsBuilder>;
|
|||||||
export const optionsBuilder = {
|
export const optionsBuilder = {
|
||||||
from: createOptions,
|
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 { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
|
||||||
import { IconClock } from "@tabler/icons-react";
|
import { IconClock } from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|||||||
@@ -1,41 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { Center, Stack, Text, UnstyledButton } from "@mantine/core";
|
import { Center, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
|
import { NoIntegrationSelectedError } from "../../errors";
|
||||||
|
|
||||||
export default function SmartHomeEntityStateWidget({
|
export default function SmartHomeEntityStateWidget({
|
||||||
options,
|
options,
|
||||||
integrationIds,
|
integrationIds,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
}: WidgetComponentProps<"smartHome-entityState">) {
|
}: WidgetComponentProps<"smartHome-entityState">) {
|
||||||
const [lastState, setLastState] = useState<{
|
const integrationId = integrationIds[0];
|
||||||
entityId: string;
|
|
||||||
state: string;
|
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();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
clientApi.widget.smartHome.subscribeEntityState.useSubscription(
|
clientApi.widget.smartHome.subscribeEntityState.useSubscription(input, {
|
||||||
{
|
onData(data) {
|
||||||
entityId: options.entityId,
|
utils.widget.smartHome.entityState.setData(input, data.state);
|
||||||
},
|
|
||||||
{
|
|
||||||
onData(data) {
|
|
||||||
setLastState(data);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({
|
|
||||||
onSettled: () => {
|
|
||||||
void utils.widget.smartHome.invalidate();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation();
|
||||||
|
|
||||||
const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : "";
|
const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : "";
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
@@ -49,9 +56,9 @@ export default function SmartHomeEntityStateWidget({
|
|||||||
|
|
||||||
mutate({
|
mutate({
|
||||||
entityId: options.entityId,
|
entityId: options.entityId,
|
||||||
integrationId: integrationIds[0] ?? "",
|
integrationId,
|
||||||
});
|
});
|
||||||
}, [integrationIds, isEditMode, mutate, options.clickable, options.entityId]);
|
}, [integrationId, isEditMode, mutate, options.clickable, options.entityId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
@@ -66,11 +73,11 @@ export default function SmartHomeEntityStateWidget({
|
|||||||
{options.displayName}
|
{options.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text ta="center">
|
<Text ta="center">
|
||||||
{lastState?.state}
|
{entityState}
|
||||||
{attribute}
|
{attribute}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionIcon, Center, LoadingOverlay, Overlay, Stack, Text, UnstyledButton } from "@mantine/core";
|
import { ActionIcon, Center, LoadingOverlay, Overlay, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||||
import { useDisclosure, useTimeout } from "@mantine/hooks";
|
import { useDisclosure, useTimeout } from "@mantine/hooks";
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core";
|
import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core";
|
||||||
import { IconArrowDownRight, IconArrowUpRight, IconMapPin } from "@tabler/icons-react";
|
import { IconArrowDownRight, IconArrowUpRight, IconMapPin } from "@tabler/icons-react";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
|
|||||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -497,6 +497,9 @@ importers:
|
|||||||
'@homarr/redis':
|
'@homarr/redis':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../redis
|
version: link:../redis
|
||||||
|
'@homarr/request-handler':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../request-handler
|
||||||
'@homarr/server-settings':
|
'@homarr/server-settings':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../server-settings
|
version: link:../server-settings
|
||||||
@@ -782,6 +785,9 @@ importers:
|
|||||||
'@homarr/redis':
|
'@homarr/redis':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../redis
|
version: link:../redis
|
||||||
|
'@homarr/request-handler':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../request-handler
|
||||||
'@homarr/server-settings':
|
'@homarr/server-settings':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../server-settings
|
version: link:../server-settings
|
||||||
@@ -1311,6 +1317,49 @@ importers:
|
|||||||
specifier: ^5.6.3
|
specifier: ^5.6.3
|
||||||
version: 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:
|
packages/server-settings:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@homarr/definitions':
|
'@homarr/definitions':
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.10.0",
|
"eslint": "^9.15.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.3"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user