From f19033e0d53d248aed678737f3d04d5d8c7815b5 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 3 Jul 2026 16:33:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20simplify=20caching=20=E2=80=94=20TanSta?= =?UTF-8?q?ck=20Query=20as=20single=20source=20of=20truth=20(#6075)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/code-quality.yml | 62 ---- .gitignore | 1 + apps/nextjs/package.json | 3 +- .../query-cache-persister.ts | 50 +--- .../app/[locale]/_client-providers/trpc.tsx | 82 +++--- .../[locale]/boards/(content)/_creator.tsx | 39 +-- .../manage/tools/docker/docker-table.tsx | 43 +-- .../components/board/items/item-content.tsx | 8 +- .../board/items/widget-context-menu.tsx | 45 ++- apps/tasks/src/on-start/index.ts | 4 +- .../invalidate-update-checker-cache.ts | 18 -- .../tasks/src/on-start/warm-update-checker.ts | 14 + mise.toml | 2 + .../analytics/src/send-server-analytics.ts | 2 - packages/api/src/query-cache.ts | 5 +- .../api/src/router/docker/docker-router.ts | 36 +-- packages/api/src/router/icons.ts | 2 - packages/api/src/router/query-cache.ts | 42 +-- .../router/test/docker/docker-router.spec.ts | 22 +- packages/api/src/router/update-checker.ts | 2 +- .../api/src/router/widgets/anchor-notes.ts | 11 +- packages/api/src/router/widgets/app.ts | 53 ---- .../router/widgets/archive-team-warrior.ts | 23 +- .../api/src/router/widgets/audio-stats.ts | 4 +- packages/api/src/router/widgets/beszel.ts | 138 +-------- packages/api/src/router/widgets/calendar.ts | 44 +-- packages/api/src/router/widgets/coolify.ts | 46 +-- packages/api/src/router/widgets/dns-hole.ts | 67 +---- packages/api/src/router/widgets/downloads.ts | 58 +--- packages/api/src/router/widgets/firewall.ts | 226 ++------------- .../src/router/widgets/health-monitoring.ts | 73 +---- packages/api/src/router/widgets/immich.ts | 6 +- .../api/src/router/widgets/indexer-manager.ts | 41 +-- .../api/src/router/widgets/media-release.ts | 62 +--- .../api/src/router/widgets/media-requests.ts | 71 +---- .../api/src/router/widgets/media-server.ts | 48 +--- .../src/router/widgets/media-transcoding.ts | 20 +- packages/api/src/router/widgets/minecraft.ts | 19 +- .../src/router/widgets/network-controller.ts | 59 +--- .../api/src/router/widgets/notifications.ts | 57 +--- .../api/src/router/widgets/paperless-ngx.ts | 2 +- packages/api/src/router/widgets/releases.ts | 4 +- packages/api/src/router/widgets/rssFeed.ts | 40 +-- packages/api/src/router/widgets/smart-home.ts | 28 +- .../src/router/widgets/speedtest-tracker.ts | 51 +--- packages/api/src/router/widgets/stocks.ts | 2 +- packages/api/src/router/widgets/timetable.ts | 17 +- packages/api/src/router/widgets/tracearr.ts | 50 +--- packages/api/src/router/widgets/umami.ts | 65 ++--- packages/api/src/router/widgets/ups.ts | 46 +-- .../api/src/router/widgets/uptime-kuma.ts | 51 +--- packages/api/src/router/widgets/vpn.ts | 50 +--- packages/api/src/router/widgets/weather.ts | 14 +- packages/api/src/services/duckduckgo-bangs.ts | 2 +- packages/api/src/settle-integrations.ts | 29 ++ packages/api/src/test/query-cache.spec.ts | 124 +++----- packages/boards/src/context.tsx | 11 +- packages/common/src/date.ts | 6 + packages/cron-jobs/src/index.ts | 52 ---- packages/cron-jobs/src/jobs/docker.ts | 31 -- .../src/jobs/integrations/dns-hole.ts | 15 - .../src/jobs/integrations/downloads.ts | 16 -- .../src/jobs/integrations/firewall.ts | 46 --- .../jobs/integrations/health-monitoring.ts | 33 --- .../src/jobs/integrations/home-assistant.ts | 16 -- .../src/jobs/integrations/indexer-manager.ts | 14 - .../src/jobs/integrations/media-organizer.ts | 36 --- .../src/jobs/integrations/media-requests.ts | 24 -- .../src/jobs/integrations/media-server.ts | 14 - .../jobs/integrations/media-transcoding.ts | 14 - .../jobs/integrations/network-controller.ts | 14 - .../src/jobs/integrations/notifications.ts | 14 - .../src/jobs/integrations/timetable.ts | 39 --- .../src/jobs/integrations/tracearr.ts | 14 - .../cron-jobs/src/jobs/integrations/umami.ts | 14 - .../cron-jobs/src/jobs/integrations/vpn.ts | 14 - .../src/jobs/minecraft-server-status.ts | 25 -- packages/cron-jobs/src/jobs/rss-feeds.ts | 39 --- packages/cron-jobs/src/jobs/update-checker.ts | 11 - packages/cron-jobs/src/jobs/weather.ts | 36 --- .../src/icon-picker/icon-picker.tsx | 10 - .../src/beszel/beszel-integration.ts | 107 ------- packages/redis/src/index.ts | 28 +- packages/redis/src/lib/channel.ts | 269 ++---------------- .../redis/src/lib/channels/history-channel.ts | 6 - packages/request-handler/src/anchor-notes.ts | 22 +- .../src/archive-team-warrior.ts | 8 +- .../request-handler/src/audiobookshelf.ts | 8 +- packages/request-handler/src/beszel.ts | 32 +-- packages/request-handler/src/calendar.ts | 6 +- packages/request-handler/src/coolify.ts | 8 +- packages/request-handler/src/dns-hole.ts | 8 +- packages/request-handler/src/docker.ts | 8 +- packages/request-handler/src/downloads.ts | 8 +- .../request-handler/src/duckduckgo-bangs.ts | 11 +- packages/request-handler/src/firewall.ts | 20 +- .../request-handler/src/health-monitoring.ts | 12 +- packages/request-handler/src/immich.ts | 16 +- .../request-handler/src/indexer-manager.ts | 8 +- .../lib/cached-integration-request-handler.ts | 49 ---- .../src/lib/cached-request-handler.ts | 160 ----------- .../cached-request-integration-job-handler.ts | 115 -------- .../src/lib/cached-widget-request-handler.ts | 36 --- .../src/lib/integration-request-handler.ts | 30 ++ .../src/lib/request-handler.ts | 60 ++++ .../src/lib/widget-request-handler.ts | 14 + packages/request-handler/src/media-release.ts | 8 +- .../request-handler/src/media-request-list.ts | 8 +- .../src/media-request-stats.ts | 8 +- packages/request-handler/src/media-server.ts | 8 +- .../request-handler/src/media-transcoding.ts | 8 +- .../src/minecraft-server-status.ts | 8 +- packages/request-handler/src/navidrome.ts | 8 +- .../request-handler/src/network-controller.ts | 8 +- packages/request-handler/src/notifications.ts | 8 +- packages/request-handler/src/paperless-ngx.ts | 8 +- .../request-handler/src/release-providers.ts | 2 +- packages/request-handler/src/releases.ts | 23 +- packages/request-handler/src/rss-feeds.ts | 8 +- .../src/smart-home-entity-state.ts | 8 +- .../request-handler/src/speedtest-tracker.ts | 8 +- packages/request-handler/src/stock-price.ts | 8 +- packages/request-handler/src/timetable.ts | 50 ++-- packages/request-handler/src/tracearr.ts | 8 +- packages/request-handler/src/umami.ts | 32 +-- .../request-handler/src/update-checker.ts | 20 +- packages/request-handler/src/ups.ts | 8 +- packages/request-handler/src/uptime-kuma.ts | 8 +- packages/request-handler/src/vpn.ts | 11 +- packages/request-handler/src/weather.ts | 8 +- packages/translation/src/lang/en.json | 76 +---- .../src/_inputs/widget-anchor-note-input.tsx | 3 - ...widget-multiReleasesRepositories-input.tsx | 2 - .../widgets/src/anchor-note/component.tsx | 16 +- packages/widgets/src/app/component.tsx | 13 +- .../widgets/src/app/ping/ping-indicator.tsx | 15 +- .../src/archive-team-warrior/component.tsx | 15 +- .../widgets/src/audio-stats/component.tsx | 7 +- .../widgets/src/beszel-alerts/component.tsx | 31 +- .../src/beszel-system-grid/component.tsx | 8 +- .../src/beszel-system-stats/component.tsx | 10 +- .../widgets/src/beszel-system-stats/index.ts | 5 +- .../src/beszel-system-table/component.tsx | 8 +- packages/widgets/src/beszel/_shared/hooks.ts | 20 -- .../src/beszel/_shared/system-stats-modal.tsx | 15 +- .../src/beszel/_shared/use-live-stats.ts | 91 +----- packages/widgets/src/calendar/component.tsx | 74 +---- packages/widgets/src/coolify/component.tsx | 22 +- .../src/dns-hole/controls/component.tsx | 94 +----- .../src/dns-hole/summary/component.tsx | 41 +-- packages/widgets/src/docker/component.tsx | 9 +- packages/widgets/src/downloads/component.tsx | 62 +--- packages/widgets/src/firewall/component.tsx | 176 +----------- .../cluster/cluster-health.tsx | 24 +- .../src/health-monitoring/system-health.tsx | 33 +-- .../src/immich/album-carousel/component.tsx | 2 +- .../src/immich/server-stats/component.tsx | 9 +- .../widgets/src/indexer-manager/component.tsx | 30 +- .../widgets/src/media-releases/component.tsx | 36 +-- .../src/media-requests/list/component.tsx | 49 +--- .../src/media-requests/stats/component.tsx | 13 +- .../widgets/src/media-server/component.tsx | 48 +--- .../src/media-transcoding/component.tsx | 13 +- .../src/minecraft/server-status/component.tsx | 13 +- .../network-status/component.tsx | 45 +-- .../network-controller/summary/component.tsx | 39 +-- .../widgets/src/notifications/component.tsx | 41 +-- .../widgets/src/paperless-ngx/component.tsx | 7 +- packages/widgets/src/rssFeed/component.tsx | 24 +- .../src/smart-home/entity-state/component.tsx | 13 +- .../src/speedtest-tracker/component.tsx | 29 +- packages/widgets/src/stocks/component.tsx | 4 +- .../widgets/src/system-disks/component.tsx | 13 +- .../src/system-resources/component.tsx | 75 +++-- packages/widgets/src/timetable/component.tsx | 15 +- packages/widgets/src/tracearr/component.tsx | 35 +-- packages/widgets/src/umami/umami-content.tsx | 26 +- .../src/umami/umami-events-content.tsx | 11 +- packages/widgets/src/umami/umami-top-list.tsx | 20 +- packages/widgets/src/umami/umami-utils.ts | 2 + packages/widgets/src/ups/component.tsx | 27 +- .../widgets/src/uptime-kuma/component.tsx | 36 +-- packages/widgets/src/vpn/component.tsx | 45 +-- packages/widgets/src/vpn/helpers.ts | 17 -- packages/widgets/src/weather/component.tsx | 11 +- pnpm-lock.yaml | 32 ++- pnpm-workspace.yaml | 3 +- 187 files changed, 1013 insertions(+), 4712 deletions(-) delete mode 100644 apps/tasks/src/on-start/invalidate-update-checker-cache.ts create mode 100644 apps/tasks/src/on-start/warm-update-checker.ts create mode 100644 mise.toml create mode 100644 packages/api/src/settle-integrations.ts delete mode 100644 packages/cron-jobs/src/jobs/docker.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/dns-hole.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/downloads.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/firewall.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/health-monitoring.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/home-assistant.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/indexer-manager.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/media-organizer.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/media-requests.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/media-server.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/media-transcoding.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/network-controller.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/notifications.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/timetable.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/tracearr.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/umami.ts delete mode 100644 packages/cron-jobs/src/jobs/integrations/vpn.ts delete mode 100644 packages/cron-jobs/src/jobs/minecraft-server-status.ts delete mode 100644 packages/cron-jobs/src/jobs/rss-feeds.ts delete mode 100644 packages/cron-jobs/src/jobs/update-checker.ts delete mode 100644 packages/cron-jobs/src/jobs/weather.ts delete mode 100644 packages/redis/src/lib/channels/history-channel.ts delete mode 100644 packages/request-handler/src/lib/cached-integration-request-handler.ts delete mode 100644 packages/request-handler/src/lib/cached-request-handler.ts delete mode 100644 packages/request-handler/src/lib/cached-request-integration-job-handler.ts delete mode 100644 packages/request-handler/src/lib/cached-widget-request-handler.ts create mode 100644 packages/request-handler/src/lib/integration-request-handler.ts create mode 100644 packages/request-handler/src/lib/request-handler.ts create mode 100644 packages/request-handler/src/lib/widget-request-handler.ts diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 98a247460..8a01559a9 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -163,65 +163,3 @@ jobs: run: cp .env.example .env - name: Build run: pnpm turbo build --affected - - memory-regression: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v6 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - name: Build Docker image - uses: docker/build-push-action@v7 - with: - platforms: linux/amd64 - context: . - push: false - load: true - tags: homarr-memory-test - network: host - cache-from: type=gha - cache-to: type=gha,mode=max - env: - SKIP_ENV_VALIDATION: true - - name: Start container and measure memory - run: | - docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test - # Wait for container to be healthy (up to 60s) - healthy=false - for i in $(seq 1 30); do - if curl -sf http://localhost:17575/api/health 2>/dev/null; then - echo "Container healthy after $((i * 2))s" - healthy=true - break - fi - sleep 2 - done - if [ "$healthy" != "true" ]; then - echo "ERROR: Container never became healthy" - docker logs homarr-mem-test --tail 50 - exit 1 - fi - # Warm up: hit the dashboard page a few times - for i in $(seq 1 5); do - curl -sf http://localhost:17575/ > /dev/null 2>&1 || true - sleep 2 - done - # Measure RSS after warmup - MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||') - echo "Raw memory: $MEM_KB" - # Convert to numeric MB (handles GiB and MiB suffixes) - MEM_NUM=$(echo "$MEM_KB" | awk '{ - if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 } - else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 } - else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 } - else { print $1 / 1048576 } - }') - echo "Memory usage: ${MEM_NUM} MB" - MAX_MB=700 - if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then - echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold" - docker logs homarr-mem-test --tail 50 - exit 1 - fi - echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB" diff --git a/.gitignore b/.gitignore index 61a76879e..c22262f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ skills-lock.json # docusaurus apps/docs/.docusaurus apps/docs/build +.pnpm-store diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index cfea600e7..36bb405ad 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -59,10 +59,11 @@ "@modelcontextprotocol/sdk": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", - "@tanstack/query-persist-client-core": "catalog:", + "@tanstack/query-async-storage-persister": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-next-experimental": "catalog:", + "@tanstack/react-query-persist-client": "catalog:", "@trpc/client": "catalog:", "@trpc/next": "catalog:", "@trpc/react-query": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts index 70d8581c7..1b9a011aa 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts @@ -1,49 +1,29 @@ -import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core"; -import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core"; -import { parse, stringify } from "superjson"; +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import { fetchApi } from "@homarr/api/client"; -import { - getActiveQueryCacheBoardId, - isPersistableWidgetQueryKey, - queryCacheStoragePrefix, -} from "@homarr/api/query-cache"; +import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache"; const queryCacheStorage = { - async getItem(_key) { - return null; - }, - async setItem(key, value) { + getItem: (_key: string) => null as string | null, + async setItem(_key: string, value: string) { const boardId = getActiveQueryCacheBoardId(); if (!boardId) return; - - try { - await fetchApi.queryCache.setItem.mutate({ boardId, key, value }); - } catch { - // Query persistence is best-effort; failed writes should not affect widget rendering. - } + await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => { + console.warn("[query-cache] persist failed", error); + }); }, - async removeItem(key) { + async removeItem(_key: string) { const boardId = getActiveQueryCacheBoardId(); if (!boardId) return; - - try { - await fetchApi.queryCache.removeItem.mutate({ boardId, key }); - } catch { - // Query persistence is best-effort; failed removals should not affect widget rendering. - } + await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => { + console.warn("[query-cache] remove failed", error); + }); }, -} satisfies AsyncStorage; +}; export const createWidgetQueryPersister = () => - experimental_createQueryPersister({ + createAsyncStoragePersister({ storage: typeof window === "undefined" ? undefined : queryCacheStorage, - maxAge: Number.POSITIVE_INFINITY, - prefix: queryCacheStoragePrefix, - refetchOnRestore: true, - serialize: (query) => stringify(query), - deserialize: (value) => parse(value), - filters: { - predicate: (query) => isPersistableWidgetQueryKey(query.queryKey), - }, + key: queryCacheStoragePrefix, + throttleTime: 2000, }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index d09acd8c8..7a02e9284 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -2,9 +2,10 @@ import type { PropsWithChildren } from "react"; import { useState } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { createWSClient, httpBatchStreamLink, @@ -21,7 +22,12 @@ import { TRPCClientError } from "@trpc/client"; import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache"; +import { + isPersistableWidgetQueryKey, + queryCacheDefaultGcTimeMs, + queryCacheDefaultRefetchIntervalMs, + queryCacheDefaultStaleTimeMs, +} from "@homarr/api/query-cache"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; @@ -29,7 +35,6 @@ import { showWarningNotification } from "@homarr/notifications"; import { createWidgetQueryPersister } from "./query-cache-persister"; const getWebSocketProtocol = () => { - // window is not defined on server side if (typeof window === "undefined") { return "ws"; } @@ -55,32 +60,36 @@ const wsClient = createWSClient({ }); export function TRPCReactProvider(props: PropsWithChildren) { - const [queryPersister] = useState(() => createWidgetQueryPersister()); - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - persister: queryPersister.persisterFn, - staleTime: queryCacheDefaultStaleTimeMs, - }, - mutations: { - onError(error) { - if ( - error instanceof TRPCClientError && - error.data?.code === "FORBIDDEN" && - error.message === "Mutations are disabled in demo mode" - ) { - showWarningNotification({ - title: "Demo mode", - message: "This action is disabled in demo mode.", - }); - } - }, + const [persister] = useState(() => createWidgetQueryPersister()); + const [queryClient] = useState(() => { + const client = new QueryClient({ + defaultOptions: { + queries: { + staleTime: queryCacheDefaultStaleTimeMs, + gcTime: queryCacheDefaultGcTimeMs, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: 3, + }, + mutations: { + onError(error) { + if ( + error instanceof TRPCClientError && + error.data?.code === "FORBIDDEN" && + error.message === "Mutations are disabled in demo mode" + ) { + showWarningNotification({ + title: "Demo mode", + message: "This action is disabled in demo mode.", + }); + } }, }, - }), - ); + }, + }); + client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); + return client; + }); const [trpcClient] = useState(() => { return clientApi.createClient({ @@ -98,9 +107,6 @@ export function TRPCReactProvider(props: PropsWithChildren) { false: splitLink({ condition: ({ input }) => isNonJsonSerializable(input), true: httpLink({ - /** - * We don't want to transform the data here as we want to use form data - */ transformer: { serialize(object: unknown) { return object; @@ -115,7 +121,7 @@ export function TRPCReactProvider(props: PropsWithChildren) { false: httpBatchStreamLink({ transformer: superjson, url: getTrpcUrl(), - maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length + maxURLLength: 2083, headers: createHeadersCallbackForSource("nextjs-react (json)"), }), }), @@ -126,10 +132,20 @@ export function TRPCReactProvider(props: PropsWithChildren) { return ( - + + query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey), + }, + }} + > {props.children} - + ); } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 2144304c5..aff5e395c 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -5,10 +5,8 @@ import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content import "~/styles/gridstack.scss"; -import type { DehydratedState } from "@tanstack/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { parse } from "superjson"; - +import type { PersistedClient } from "@tanstack/react-query-persist-client"; import { getQueryClient } from "@homarr/api/server"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; @@ -17,7 +15,7 @@ import { isNullOrWhitespace } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { WidgetKind } from "@homarr/definitions"; -import { getAllQueryCacheAsync } from "@homarr/redis"; +import { getQueryCacheAsync } from "@homarr/redis"; import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; @@ -118,36 +116,17 @@ export const createBoardContentPage = >( }; }; -interface PersistedEntry { - state: { data: unknown; dataUpdatedAt: number; status: string }; - queryKey: unknown[]; - queryHash: string; -} - async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) { try { - const entries = await getAllQueryCacheAsync(userId, boardId); - const queries: DehydratedState["queries"] = []; + const serialized = await getQueryCacheAsync(userId, boardId); + if (!serialized) return null; - for (const serialized of Object.values(entries)) { - try { - const persisted = parse(serialized); - if (persisted.state.status !== "success") continue; + const persisted = JSON.parse(serialized) as PersistedClient; + if (!persisted?.clientState?.queries?.length) return null; - queries.push({ - state: persisted.state as DehydratedState["queries"][number]["state"], - queryKey: persisted.queryKey, - queryHash: persisted.queryHash, - }); - } catch { - // skip malformed entries - } - } - - if (queries.length === 0) return null; - - return ; - } catch { + return ; + } catch (error) { + logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error })); return null; } } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index 764f91844..43bbc3ff3 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCallback } from "react"; import { useRouter } from "next/navigation"; import type { MantineColor } from "@mantine/core"; import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core"; @@ -156,29 +157,26 @@ interface DockerTableProps { export function DockerTable({ initialData }: DockerTableProps) { const t = useI18n(); const tDocker = useScopedI18n("docker"); - const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, { + const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, { initialData, refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, }); const relativeTime = useTimeAgo(data?.timestamp ?? new Date()); - const utils = clientApi.useUtils(); - const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ - async onSuccess() { - await utils.docker.getContainers.invalidate(); - showSuccessNotification({ - title: tDocker("action.refresh.notification.success.title"), - message: tDocker("action.refresh.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: tDocker("action.refresh.notification.error.title"), - message: tDocker("action.refresh.notification.error.message"), - }); - }, - }); + const mutate = useCallback(() => { + void refetch().then((result) => { + if (result.isError) { + showErrorNotification({ + title: tDocker("action.refresh.notification.error.title"), + message: tDocker("action.refresh.notification.error.message"), + }); + } else { + showSuccessNotification({ + title: tDocker("action.refresh.notification.success.title"), + message: tDocker("action.refresh.notification.success.message"), + }); + } + }); + }, [refetch, tDocker]); const containers = data?.containers ?? []; @@ -207,7 +205,12 @@ export function DockerTable({ initialData }: DockerTableProps) { mantineTableHeadCellProps: { style: { padding: "4px 8px" } }, renderRowActions: ({ row }: { row: MRT_Row }) => , renderTopToolbarCustomActions: () => ( - ), diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 3d7ecfd3a..d869538e4 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -2,7 +2,7 @@ import type { MutableRefObject } from "react"; import { useRef } from "react"; import { Badge, Card } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; -import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import { ErrorBoundary } from "react-error-boundary"; @@ -105,12 +105,16 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { updateItemOptions({ itemId: item.id, newOptions }); const widgetSupportsIntegrations = "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; + const queryClient = useQueryClient(); return ( {({ reset }) => ( { + void queryClient.invalidateQueries({ queryKey: [["widget"]] }); + reset(); + }} fallbackRender={({ resetErrorBoundary, error }) => ( <> diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 1dc960f58..415151a52 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,9 +1,11 @@ "use client"; import type { MutableRefObject, ReactNode } from "react"; -import { useCallback, useMemo } from "react"; -import { Group, Menu, Switch } from "@mantine/core"; -import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Group, Menu, Switch, Text } from "@mantine/core"; +import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react"; +import type { QueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -45,6 +47,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); const { gridstack } = useSectionContext().refs; + const queryClient = useQueryClient(); + + const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]); + const handleRefetch = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: widgetQueryKey }); + }, [queryClient, widgetQueryKey]); const options = useMemo( () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, @@ -214,7 +222,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont )} <> - {!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } + } onClick={handleRefetch}> + + {tMenu("refresh")} + + + + + } @@ -248,3 +264,24 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont ); }; + +const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => { + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((n) => n + 1), 1000); + return () => clearInterval(id); + }, []); + + const timestamps = queryClient + .getQueryCache() + .findAll({ queryKey }) + .map((q) => q.state.dataUpdatedAt) + .filter(Boolean); + if (timestamps.length === 0) return null; + + const oldest = Math.min(...timestamps); + const seconds = Math.floor((Date.now() - oldest) / 1000); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + return `${Math.floor(seconds / 60)}m ago`; +}; diff --git a/apps/tasks/src/on-start/index.ts b/apps/tasks/src/on-start/index.ts index cb9564d1b..cc6967dce 100644 --- a/apps/tasks/src/on-start/index.ts +++ b/apps/tasks/src/on-start/index.ts @@ -1,7 +1,7 @@ -import { invalidateUpdateCheckerCacheAsync } from "./invalidate-update-checker-cache"; import { cleanupSessionsAsync } from "./session-cleanup"; +import { warmUpdateCheckerAsync } from "./warm-update-checker"; export async function onStartAsync() { await cleanupSessionsAsync(); - await invalidateUpdateCheckerCacheAsync(); + await warmUpdateCheckerAsync(); } diff --git a/apps/tasks/src/on-start/invalidate-update-checker-cache.ts b/apps/tasks/src/on-start/invalidate-update-checker-cache.ts deleted file mode 100644 index 470989dcf..000000000 --- a/apps/tasks/src/on-start/invalidate-update-checker-cache.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker"; - -const logger = createLogger({ module: "invalidateUpdateCheckerCache" }); - -/** - * Invalidates the update checker cache on startup to ensure fresh data. - * It is important as we want to avoid showing pending updates after the update to latest version. - */ -export async function invalidateUpdateCheckerCacheAsync() { - try { - const handler = updateCheckerRequestHandler.handler({}); - await handler.invalidateAsync(); - logger.debug("Update checker cache invalidated"); - } catch (error) { - logger.error(new Error("Failed to invalidate update checker cache", { cause: error })); - } -} diff --git a/apps/tasks/src/on-start/warm-update-checker.ts b/apps/tasks/src/on-start/warm-update-checker.ts new file mode 100644 index 000000000..a3b9f94a9 --- /dev/null +++ b/apps/tasks/src/on-start/warm-update-checker.ts @@ -0,0 +1,14 @@ +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker"; + +const logger = createLogger({ module: "warmUpdateChecker" }); + +export async function warmUpdateCheckerAsync() { + try { + const handler = updateCheckerRequestHandler.handler({}); + await handler.getDataAsync(); + logger.debug("Update checker warmed"); + } catch (error) { + logger.error(new Error("Failed to warm update checker", { cause: error })); + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..d6bd41fc8 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "24.18.0" diff --git a/packages/analytics/src/send-server-analytics.ts b/packages/analytics/src/send-server-analytics.ts index 2a961d94c..b851f86ec 100644 --- a/packages/analytics/src/send-server-analytics.ts +++ b/packages/analytics/src/send-server-analytics.ts @@ -36,8 +36,6 @@ const logger = createLogger({ module: "analytics" }); type AnalyticsResult = "sent" | "disabled" | "failed"; -// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins -// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL. const getOrCreateInstanceId = async ( analyticsSettings: Awaited>>, ): Promise => { diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index 690059723..965c21aae 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -1,7 +1,8 @@ import type { QueryKey } from "@tanstack/react-query"; -export const queryCacheRetentionMs = 1000 * 60 * 60 * 24; -export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5; +export const queryCacheDefaultStaleTimeMs = 0; +export const queryCacheDefaultRefetchIntervalMs = 30_000; +export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24; export const queryCacheMaxValueBytes = 1024 * 1024; export const queryCacheStoragePrefix = "homarr-widget-query"; diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 0011545d0..44b4ce381 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -25,9 +25,7 @@ export const dockerRouter = createTRPCRouter({ .concat(dockerMiddleware()) .query(async () => { const innerHandler = dockerContainersRequestHandler.handler({}); - const result = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + const result = await innerHandler.getDataAsync(); const { data, timestamp } = result; @@ -36,26 +34,6 @@ export const dockerRouter = createTRPCRouter({ timestamp, }; }), - subscribeContainers: permissionRequiredProcedure - .requiresPermission("admin") - .concat(dockerMiddleware()) - .subscription(() => { - return observable((emit) => { - const innerHandler = dockerContainersRequestHandler.handler({}); - const unsubscribe = innerHandler.subscribe((data) => { - emit.next(data); - }); - - return unsubscribe; - }); - }), - invalidate: permissionRequiredProcedure - .requiresPermission("admin") - .concat(dockerMiddleware()) - .mutation(async () => { - const innerHandler = dockerContainersRequestHandler.handler({}); - await innerHandler.invalidateAsync(); - }), startAll: permissionRequiredProcedure .requiresPermission("admin") .meta({ @@ -73,9 +51,6 @@ export const dockerRouter = createTRPCRouter({ await container.start(); }), ); - - const innerHandler = dockerContainersRequestHandler.handler({}); - await innerHandler.invalidateAsync(); }), stopAll: permissionRequiredProcedure .requiresPermission("admin") @@ -94,9 +69,6 @@ export const dockerRouter = createTRPCRouter({ await container.stop(); }), ); - - const innerHandler = dockerContainersRequestHandler.handler({}); - await innerHandler.invalidateAsync(); }), restartAll: permissionRequiredProcedure .requiresPermission("admin") @@ -116,9 +88,6 @@ export const dockerRouter = createTRPCRouter({ await container.restart(); }), ); - - const innerHandler = dockerContainersRequestHandler.handler({}); - await innerHandler.invalidateAsync(); }), removeAll: permissionRequiredProcedure .requiresPermission("admin") @@ -138,9 +107,6 @@ export const dockerRouter = createTRPCRouter({ await container.remove(); }), ); - - const innerHandler = dockerContainersRequestHandler.handler({}); - await innerHandler.invalidateAsync(); }), logs: permissionRequiredProcedure .requiresPermission("admin") diff --git a/packages/api/src/router/icons.ts b/packages/api/src/router/icons.ts index 4f0e59d33..6758dd012 100644 --- a/packages/api/src/router/icons.ts +++ b/packages/api/src/router/icons.ts @@ -30,8 +30,6 @@ export const iconsRouter = createTRPCRouter({ columns: { id: true, name: true, url: true }, where: whereCondition, orderBy: (table, { asc, sql }) => { - // ponytail: SVG first, then alphabetical; with a search term, - // prepend a relevance tier (exact > prefix > substring). const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`; const nameAsc = asc(table.name); if (term.length === 0) { diff --git a/packages/api/src/router/query-cache.ts b/packages/api/src/router/query-cache.ts index 6143501fc..e2aa77b9b 100644 --- a/packages/api/src/router/query-cache.ts +++ b/packages/api/src/router/query-cache.ts @@ -2,53 +2,41 @@ import { z } from "zod/v4"; import { eq } from "@homarr/db"; import { boards } from "@homarr/db/schema"; -import { createQueryCacheChannel } from "@homarr/redis"; +import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis"; -import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache"; +import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache"; import { createTRPCRouter, publicProcedure } from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; -const queryCacheKeyInput = z.object({ +const queryCacheBoardInput = z.object({ boardId: z.string().min(1), - key: z.string().min(1).max(4096), }); -const queryCacheSetInput = queryCacheKeyInput.extend({ +const queryCacheSetInput = queryCacheBoardInput.extend({ value: z.string().max(queryCacheMaxValueBytes * 2), }); -const createChannel = (input: z.infer, userId: string) => - createQueryCacheChannel({ - userId, - boardId: input.boardId, - key: input.key, - ttlMs: queryCacheRetentionMs, - maxValueBytes: queryCacheMaxValueBytes, - }); - const validateBoardAccessAsync = async (ctx: Parameters[0], boardId: string) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view"); }; export const queryCacheRouter = createTRPCRouter({ - getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => { - await validateBoardAccessAsync(ctx, input.boardId); - - const channel = createChannel(input, ctx.session?.user.id ?? "anonymous"); - return await channel.getAsync(); - }), setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => { await validateBoardAccessAsync(ctx, input.boardId); - - const channel = createChannel(input, ctx.session?.user.id ?? "anonymous"); + const userId = ctx.session?.user.id ?? "anonymous"; return { - stored: await channel.setAsync(input.value), + stored: await setQueryCacheAsync( + userId, + input.boardId, + input.value, + queryCacheDefaultGcTimeMs, + queryCacheMaxValueBytes, + ), }; }), - removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => { + removeItem: publicProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => { await validateBoardAccessAsync(ctx, input.boardId); - - const channel = createChannel(input, ctx.session?.user.id ?? "anonymous"); - await channel.removeAsync(); + const userId = ctx.session?.user.id ?? "anonymous"; + await removeQueryCacheAsync(userId, input.boardId); }), }); diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index cbea4063b..4a5a32a88 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -23,28 +23,12 @@ vi.mock("@homarr/request-handler/docker", () => ({ }, dockerContainersRequestHandler: { handler: () => ({ - getCachedOrUpdatedDataAsync: async () => { - return await Promise.resolve({ containers: [] }); - }, - invalidateAsync: async () => { - return await Promise.resolve(); + getDataAsync: async () => { + return await Promise.resolve({ data: [], timestamp: new Date() }); }, }), }, })); -vi.mock("@homarr/redis", () => ({ - createCacheChannel: () => ({ - // eslint-disable-next-line @typescript-eslint/require-await - consumeAsync: async () => ({ - timestamp: new Date().toISOString(), - data: { containers: [] }, - }), - // eslint-disable-next-line @typescript-eslint/no-empty-function - invalidateAsync: async () => {}, - }), - createWidgetOptionsChannel: () => ({}), -})); - vi.mock("@homarr/docker/env", () => ({ env: { ENABLE_DOCKER: true, @@ -67,12 +51,10 @@ const validInputs: { [key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key]; } = { getContainers: undefined, - subscribeContainers: undefined, startAll: { ids: ["1"] }, stopAll: { ids: ["1"] }, restartAll: { ids: ["1"] }, removeAll: { ids: ["1"] }, - invalidate: undefined, logs: { id: "1", tail: 200 }, subscribeLogs: { id: "1", tail: 200 }, }; diff --git a/packages/api/src/router/update-checker.ts b/packages/api/src/router/update-checker.ts index 8957be5f2..8cb6c097e 100644 --- a/packages/api/src/router/update-checker.ts +++ b/packages/api/src/router/update-checker.ts @@ -9,7 +9,7 @@ export const updateCheckerRouter = createTRPCRouter({ getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => { try { const handler = updateCheckerRequestHandler.handler({}); - const data = await handler.getCachedOrUpdatedDataAsync({}); + const data = await handler.getDataAsync(); return data.data.availableUpdates; } catch (error) { logger.error(new Error("Failed to get available updates", { cause: error })); diff --git a/packages/api/src/router/widgets/anchor-notes.ts b/packages/api/src/router/widgets/anchor-notes.ts index 7e5b7043c..d431c0de9 100644 --- a/packages/api/src/router/widgets/anchor-notes.ts +++ b/packages/api/src/router/widgets/anchor-notes.ts @@ -45,9 +45,7 @@ export const anchorNotesRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 }); - const { data } = await handler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + const { data } = await handler.getDataAsync(); return data; }), @@ -57,9 +55,7 @@ export const anchorNotesRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId }); - const { data } = await handler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + const { data } = await handler.getDataAsync(); return data; }), @@ -79,9 +75,6 @@ export const anchorNotesRouter = createTRPCRouter({ ...(normalizedContent !== undefined ? { content: normalizedContent } : {}), }); - const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId }); - await noteHandler.invalidateAsync(); - return updatedNote; } catch (error) { if (error instanceof ResponseError && error.statusCode === 400) { diff --git a/packages/api/src/router/widgets/app.ts b/packages/api/src/router/widgets/app.ts index d07241725..92e1298da 100644 --- a/packages/api/src/router/widgets/app.ts +++ b/packages/api/src/router/widgets/app.ts @@ -1,10 +1,8 @@ import { TRPCError } from "@trpc/server"; -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { sendPingRequestAsync } from "@homarr/ping"; -import { pingChannel, pingUrlChannel } from "@homarr/redis"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { AppRepository } from "../app"; @@ -45,55 +43,4 @@ export const appRouter = createTRPCRouter({ ...pingResult, }; }), - updatedPing: publicProcedure - .input( - z.object({ - id: z.string(), - }), - ) - .subscription(async ({ ctx, input }) => { - const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board"); - if (boardSettings.forceDisableStatus) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Ping functionality is disabled by server settings", - }); - } - - const repository = new AppRepository(ctx.db, ctx.session?.user ?? null); - const app = await repository.getByIdAsync(input.id); - - if (!app) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "App not found", - }); - } - - const pingUrl = app.pingUrl ?? app.href; - - if (!pingUrl) { - throw new TRPCError({ - code: "CONFLICT", - message: "No URL to ping configured for this app", - }); - } - - await pingUrlChannel.addAsync(pingUrl); - - return observable<{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }>( - (emit) => { - const unsubscribe = pingChannel.subscribe((message) => { - // Only emit if same url - if (message.url !== pingUrl) return; - emit.next(message); - }); - - return () => { - unsubscribe(); - void pingUrlChannel.removeAsync(pingUrl); - }; - }, - ); - }), }); diff --git a/packages/api/src/router/widgets/archive-team-warrior.ts b/packages/api/src/router/widgets/archive-team-warrior.ts index 0824c165a..d71b7546a 100644 --- a/packages/api/src/router/widgets/archive-team-warrior.ts +++ b/packages/api/src/router/widgets/archive-team-warrior.ts @@ -1,6 +1,3 @@ -import { observable } from "@trpc/server/observable"; - -import type { ArchiveTeamWarriorStatus } from "@homarr/integrations"; import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; @@ -11,29 +8,11 @@ export const archiveTeamWarriorRouter = createTRPCRouter({ .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior")) .query(async ({ ctx }) => { const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {}); - const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data, timestamp } = await handler.getDataAsync(); return { status: data, updatedAt: timestamp, }; }), - - subscribeStatus: publicProcedure - .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior")) - .subscription(({ ctx }) => { - return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => { - const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {}); - const unsubscribe = handler.subscribe((status) => { - emit.next({ - status, - updatedAt: new Date(), - }); - }); - - return () => { - unsubscribe(); - }; - }); - }), }); diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts index d43876583..14fd7d49c 100644 --- a/packages/api/src/router/widgets/audio-stats.ts +++ b/packages/api/src/router/widgets/audio-stats.ts @@ -13,14 +13,14 @@ export const audioStatsRouter = createTRPCRouter({ const fetchByKind = { navidrome: async () => { const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {}); - return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; + return (await handler.getDataAsync()).data; }, audiobookshelf: async () => { const handler = audiobookshelfRequestHandler.handler( { ...ctx.integration, kind: "audiobookshelf" as const }, {}, ); - return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; + return (await handler.getDataAsync()).data; }, } as const; diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index cbb453d03..797de8609 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -1,16 +1,12 @@ import { TRPCError } from "@trpc/server"; -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createIntegrationAsync } from "@homarr/integrations"; -import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; import { beszelAlertsRequestHandler, beszelStatsRequestHandler, beszelSystemsRequestHandler, } from "@homarr/request-handler/beszel"; -import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -33,9 +29,7 @@ export const beszelRouter = createTRPCRouter({ const settled = await Promise.allSettled( ctx.integrations.map(async (integration) => { const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + const { data, timestamp } = await innerHandler.getDataAsync(); return { integrationId: integration.id, integrationName: integration.name, @@ -71,30 +65,6 @@ export const beszelRouter = createTRPCRouter({ return results; }), - subscribeSystems: publicProcedure - .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) - .subscription(({ ctx }) => { - return observable<{ - integrationId: string; - systems: BeszelSystemRow[]; - timestamp: Date; - }>((emit) => { - const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); - return innerHandler.subscribe((systems) => { - emit.next({ - integrationId: integration.id, - systems, - timestamp: new Date(), - }); - }); - }); - return () => { - unsubscribes.forEach((fn) => fn()); - }; - }); - }), - getAlerts: publicProcedure .meta({ mcp: { @@ -125,13 +95,17 @@ export const beszelRouter = createTRPCRouter({ maxHistoryItems: input.maxHistoryItems, }); const systemsHandler = beszelSystemsRequestHandler.handler(integration, {}); - const [alertsResult, systemsResult] = await Promise.all([ - alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), - systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), + const [alertsSettled, systemsSettled] = await Promise.allSettled([ + alertsHandler.getDataAsync(), + systemsHandler.getDataAsync(), ]); + if (alertsSettled.status === "rejected") throw alertsSettled.reason; + const alertsResult = alertsSettled.value; const systemNameMap: Record = {}; - for (const system of systemsResult.data) { - systemNameMap[system.id] = system.name; + if (systemsSettled.status === "fulfilled") { + for (const system of systemsSettled.value.data) { + systemNameMap[system.id] = system.name; + } } return { integrationId: integration.id, @@ -170,39 +144,6 @@ export const beszelRouter = createTRPCRouter({ return results; }), - subscribeAlerts: publicProcedure - .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) - .input( - z.object({ - includeHistory: z.boolean().default(true), - maxHistoryItems: z.number().min(1).max(100).default(10), - }), - ) - .subscription(({ ctx, input }) => { - return observable<{ - integrationId: string; - alerts: BeszelAlertsData; - timestamp: Date; - }>((emit) => { - const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = beszelAlertsRequestHandler.handler(integration, { - includeHistory: input.includeHistory, - maxHistoryItems: input.maxHistoryItems, - }); - return innerHandler.subscribe((data) => { - emit.next({ - integrationId: integration.id, - alerts: data, - timestamp: new Date(), - }); - }); - }); - return () => { - unsubscribes.forEach((fn) => fn()); - }; - }); - }), - getSystemStats: publicProcedure .meta({ mcp: { @@ -237,7 +178,7 @@ export const beszelRouter = createTRPCRouter({ timePeriod: input.timePeriod, includeDocker: input.includeDocker, }); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data, timestamp } = await innerHandler.getDataAsync(); logger.debug("getSystemStats completed", { userId: ctx.session?.user?.id, integrationId: integration.id, @@ -260,61 +201,4 @@ export const beszelRouter = createTRPCRouter({ }; } }), - - subscribeSystemStats: publicProcedure - .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) - .input( - z.object({ - systemId: z.string(), - }), - ) - .subscription(({ ctx, input }) => { - return observable<{ - stats: BeszelSystemStatsRecord; - containerStats: BeszelContainerStatsRecord | null; - }>((emit) => { - const integration = ctx.integrations[0]; - if (!integration) { - emit.error(new Error("No Beszel integration configured")); - return () => {}; - } - logger.debug("subscribeSystemStats opened", { - userId: ctx.session?.user?.id, - integrationId: integration.id, - systemId: input.systemId, - }); - - const abortController = new AbortController(); - - void (async () => { - try { - const instance = await createIntegrationAsync(integration); - await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal); - } catch (error) { - if (!abortController.signal.aborted) { - logger.warn("subscribeSystemStats error", { - userId: ctx.session?.user?.id, - integrationId: integration.id, - systemId: input.systemId, - error, - }); - if (error instanceof Error) { - emit.error(error); - } else { - emit.error(new Error(String(error))); - } - } - } - })(); - - return () => { - abortController.abort(); - logger.debug("subscribeSystemStats closed", { - userId: ctx.session?.user?.id, - integrationId: integration.id, - systemId: input.systemId, - }); - }; - }); - }), }); diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 50ba71b3e..729e82634 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,13 +1,8 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; -import type { Modify } from "@homarr/common/types"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import type { Integration } from "@homarr/db/schema"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { CalendarEvent } from "@homarr/integrations/types"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; @@ -39,9 +34,7 @@ export const calendarRouter = createTRPCRouter({ ctx.integrations.map(async (integration) => { const { integrationIds: _integrationIds, ...handlerInput } = input; const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + const { data } = await innerHandler.getDataAsync(); return { events: data, @@ -72,39 +65,4 @@ export const calendarRouter = createTRPCRouter({ return []; }); }), - subscribeToEvents: publicProcedure - .input( - z.object({ - year: z.number(), - month: z.number(), - releaseType: z.array(z.enum(radarrReleaseTypes)), - showUnmonitored: z.boolean(), - }), - ) - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) - .subscription(({ ctx, input }) => { - return observable<{ - integration: Modify }>; - events: CalendarEvent[]; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const { integrationIds: _integrationIds, ...handlerInput } = input; - const innerHandler = calendarMonthRequestHandler.handler(integrationWithSecrets, handlerInput); - const unsubscribe = innerHandler.subscribe((events) => { - emit.next({ - integration, - events, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }); - }), }); diff --git a/packages/api/src/router/widgets/coolify.ts b/packages/api/src/router/widgets/coolify.ts index 8bdaa6f3e..9476039d9 100644 --- a/packages/api/src/router/widgets/coolify.ts +++ b/packages/api/src/router/widgets/coolify.ts @@ -1,49 +1,23 @@ -import { observable } from "@trpc/server/observable"; - -import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; import { coolifyRequestHandler } from "@homarr/request-handler/coolify"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const coolifyRouter = createTRPCRouter({ getInstancesInfo: publicProcedure .concat(createManyIntegrationMiddleware("query", "coolify")) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = coolifyRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = coolifyRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - instanceInfo: data, - updatedAt: timestamp, - }; - }), - ); - - return results; - }), - subscribeInstancesInfo: publicProcedure - .concat(createManyIntegrationMiddleware("query", "coolify")) - .subscription(({ ctx }) => { - return observable<{ integrationId: string; instanceInfo: CoolifyInstanceInfo; timestamp: Date }>((emit) => { - const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = coolifyRequestHandler.handler(integration, {}); - return innerHandler.subscribe((instanceInfo) => { - emit.next({ - integrationId: integration.id, - instanceInfo, - timestamp: new Date(), - }); - }); - }); - - return () => { - unsubscribes.forEach((unsubscribe) => unsubscribe()); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + instanceInfo: data, + updatedAt: timestamp, }; }); }), diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 93162a1e3..32f15da98 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -1,15 +1,11 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; -import type { Modify } from "@homarr/common/types"; -import type { Integration } from "@homarr/db/schema"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; -import type { DnsHoleSummary } from "@homarr/integrations/types"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; export const dnsHoleRouter = createTRPCRouter({ @@ -23,50 +19,11 @@ export const dnsHoleRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = dnsHoleRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); - - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - summary: data, - }; - }), - ); - return results; - }), - - subscribeToSummary: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) - .subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - summary: DnsHoleSummary; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((summary) => { - emit.next({ - integration, - summary, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + summary: data, }; }); }), @@ -83,12 +40,6 @@ export const dnsHoleRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration } }) => { const client = await createIntegrationAsync(integration); await client.enableAsync(); - - const innerHandler = dnsHoleRequestHandler.handler(integration, {}); - // We need to wait for the integration to be enabled before invalidating the cache - await new Promise((resolve) => { - setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000); - }); }), disable: protectedProcedure @@ -108,11 +59,5 @@ export const dnsHoleRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); await client.disableAsync(input.duration); - - const innerHandler = dnsHoleRequestHandler.handler(integration, {}); - // We need to wait for the integration to be disabled before invalidating the cache - await new Promise((resolve) => { - setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000); - }); }), }); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index 682decdc4..50aee350e 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -1,16 +1,12 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; -import type { Modify } from "@homarr/common/types"; -import type { Integration } from "@homarr/db/schema"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations"; import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) => @@ -28,52 +24,12 @@ export const downloadsRouter = createTRPCRouter({ .concat(createDownloadClientIntegrationMiddleware("query")) .input(z.object({ limitPerIntegration: z.number().default(50) })) .query(async ({ ctx, input }) => { - return await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); - - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); - - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - data, - }; - }), - ); - }), - subscribeToJobsAndStatuses: publicProcedure - .concat(createDownloadClientIntegrationMiddleware("query")) - .input(z.object({ limitPerIntegration: z.number().default(50) })) - .subscription(({ ctx, input }) => { - return observable<{ - integration: Modify }>; - data: DownloadClientJobsAndStatus; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, { - limit: input.limitPerIntegration, - }); - const unsubscribe = innerHandler.subscribe((data) => { - emit.next({ - integration, - data, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); + const { data, timestamp } = await innerHandler.getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + data, }; }); }), diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index 2d582cb1f..08951da7c 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -1,15 +1,4 @@ -import { observable } from "@trpc/server/observable"; - -import type { Modify } from "@homarr/common/types"; -import type { Integration } from "@homarr/db/schema"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { - FirewallCpuSummary, - FirewallInterfacesSummary, - FirewallMemorySummary, - FirewallVersionSummary, -} from "@homarr/integrations"; import { firewallCpuRequestHandler, firewallInterfacesRequestHandler, @@ -18,198 +7,31 @@ import { } from "@homarr/request-handler/firewall"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")); + +const queryFirewall = < + THandler extends { + handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> }; + }, +>( + handler: THandler, +) => + publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => + settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + summary: data as Awaited["getDataAsync"]>>["data"], + }; + }), + ); + export const firewallRouter = createTRPCRouter({ - getFirewallCpuStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) - .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = firewallCpuRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - summary: data, - }; - }), - ); - return results; - }), - subscribeFirewallCpuStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) - .subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - summary: FirewallCpuSummary; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = firewallCpuRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((summary) => { - emit.next({ - integration, - summary, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }); - }), - - getFirewallInterfacesStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) - .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = firewallInterfacesRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - summary: data, - }; - }), - ); - return results; - }), - subscribeFirewallInterfacesStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) - .subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - summary: FirewallInterfacesSummary[]; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = firewallInterfacesRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((summary) => { - emit.next({ - integration, - summary, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }); - }), - - getFirewallVersionStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) - .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = firewallVersionRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - summary: data, - }; - }), - ); - return results; - }), - subscribeFirewallVersionStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) - .subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - summary: FirewallVersionSummary; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = firewallVersionRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((summary) => { - emit.next({ - integration, - summary, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }); - }), - - getFirewallMemoryStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) - .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = firewallMemoryRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - summary: data, - }; - }), - ); - return results; - }), - subscribeFirewallMemoryStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) - .subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - summary: FirewallMemorySummary; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = firewallMemoryRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((summary) => { - emit.next({ - integration, - summary, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }); - }), + getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler), + getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler), + getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler), + getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler), }); diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 03856f5a0..de49a1083 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,10 +1,7 @@ -import { observable } from "@trpc/server/observable"; - -import type { SystemHealthMonitoring } from "@homarr/integrations"; -import type { ProxmoxClusterInfo } from "@homarr/integrations/types"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const healthMonitoringRouter = createTRPCRouter({ @@ -20,48 +17,13 @@ export const healthMonitoringRouter = createTRPCRouter({ createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"), ) .query(async ({ ctx }) => { - return await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = systemInfoRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); - - return { - integrationId: integration.id, - integrationName: integration.name, - healthInfo: data, - updatedAt: timestamp, - }; - }), - ); - }), - subscribeSystemHealthStatus: publicProcedure - .concat( - createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"), - ) - .subscription(({ ctx }) => { - return observable<{ - integrationId: string; - healthInfo: SystemHealthMonitoring; - timestamp: Date; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integration of ctx.integrations) { - const innerHandler = systemInfoRequestHandler.handler(integration, {}); - const unsubscribe = innerHandler.subscribe((healthInfo) => { - emit.next({ - integrationId: integration.id, - healthInfo, - timestamp: new Date(), - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + healthInfo: data, + updatedAt: timestamp, }; }); }), @@ -76,24 +38,7 @@ export const healthMonitoringRouter = createTRPCRouter({ .concat(createOneIntegrationMiddleware("query", "proxmox", "mock")) .query(async ({ ctx }) => { const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + const { data } = await innerHandler.getDataAsync(); return data; }), - subscribeClusterHealthStatus: publicProcedure - .concat(createOneIntegrationMiddleware("query", "proxmox", "mock")) - .subscription(({ ctx }) => { - return observable((emit) => { - const unsubscribes: (() => void)[] = []; - const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); - const unsubscribe = innerHandler.subscribe((healthInfo) => { - emit.next(healthInfo); - }); - unsubscribes.push(unsubscribe); - return () => { - unsubscribe(); - }; - }); - }), }); diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts index f1428a80c..7e8c54d49 100644 --- a/packages/api/src/router/widgets/immich.ts +++ b/packages/api/src/router/widgets/immich.ts @@ -17,7 +17,7 @@ const createImmichIntegrationMiddleware = (action: IntegrationAction) => export const immichRouter = createTRPCRouter({ getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {}); - const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const data = await innerHandler.getDataAsync(); return data.data; }), @@ -30,13 +30,13 @@ export const immichRouter = createTRPCRouter({ ) .query(async ({ ctx, input }) => { const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId }); - const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const data = await innerHandler.getDataAsync(); return data.data; }), getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {}); - const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const data = await innerHandler.getDataAsync(); return data.data; }), }); diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index cb8b88262..54a949dab 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -1,13 +1,12 @@ import { TRPCError } from "@trpc/server"; -import { observable } from "@trpc/server/observable"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; -import type { Indexer } from "@homarr/integrations/types"; import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager"; import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => @@ -17,39 +16,13 @@ export const indexerManagerRouter = createTRPCRouter({ getIndexersStatus: publicProcedure .concat(createIndexerManagerIntegrationMiddleware("query")) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = indexerManagerRequestHandler.handler(integration, {}); - const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = indexerManagerRequestHandler.handler(integration, {}); + const { data: indexers } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - indexers, - }; - }), - ); - return results; - }), - - subscribeIndexersStatus: publicProcedure - .concat(createIndexerManagerIntegrationMiddleware("query")) - .subscription(({ ctx }) => { - return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((indexers) => { - emit.next({ - integrationId: integrationWithSecrets.id, - indexers, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); + return { + integrationId: integration.id, + indexers, }; }); }), diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index fddc174cc..f05a46142 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -1,35 +1,28 @@ -import { observable } from "@trpc/server/observable"; - -import type { Modify } from "@homarr/common/types"; -import type { Integration } from "@homarr/db/schema"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { MediaRelease } from "@homarr/integrations/types"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const mediaReleaseRouter = createTRPCRouter({ getMediaReleases: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - releases: data, - }; - }), - ); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + releases: data, + }; + }); return results.flatMap((result) => result.releases.map((release) => ({ ...release, @@ -37,31 +30,4 @@ export const mediaReleaseRouter = createTRPCRouter({ })), ); }), - - subscribeToReleases: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) - .subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - releases: MediaRelease[]; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = mediaReleaseRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((releases) => { - emit.next({ - integration, - releases, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }); - }), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 715ae1327..99c4906e3 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -1,14 +1,13 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; -import type { MediaRequest } from "@homarr/integrations/types"; import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; export const mediaRequestsRouter = createTRPCRouter({ @@ -22,22 +21,10 @@ export const mediaRequestsRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = mediaRequestListRequestHandler.handler(integration, {}); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - }, - data, - }; - }), - ); + const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync(); + return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; + }); return results .flatMap(({ data, integration }) => data.map((request) => ({ @@ -56,32 +43,6 @@ export const mediaRequestsRouter = createTRPCRouter({ ); }); }), - subscribeToLatestRequests: publicProcedure - .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 .meta({ mcp: { @@ -92,22 +53,10 @@ export const mediaRequestsRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {}); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - }, - data, - }; - }), - ); + const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); + return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; + }); return { stats: results.flatMap((result) => result.data.stats), users: results @@ -139,7 +88,6 @@ export const mediaRequestsRouter = createTRPCRouter({ ) .mutation(async ({ ctx: { integration }, input }) => { const integrationInstance = await createIntegrationAsync(integration); - const innerHandler = mediaRequestListRequestHandler.handler(integration, {}); const answerActions = { approve: (id: number) => integrationInstance.approveRequestAsync(id), @@ -147,6 +95,5 @@ export const mediaRequestsRouter = createTRPCRouter({ } as const; await answerActions[input.answer](input.requestId); - await innerHandler.invalidateAsync(); }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index de61fef75..3e3e64a4f 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -1,12 +1,11 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { StreamSession } from "@homarr/integrations"; import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"; import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => @@ -24,46 +23,11 @@ export const mediaServerRouter = createTRPCRouter({ .concat(createMediaServerIntegrationMiddleware("query")) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { - return await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = mediaServerRequestHandler.handler(integration, { - showOnlyPlaying: input.showOnlyPlaying, - }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); - return { - integrationId: integration.id, - integrationKind: integration.kind, - sessions: data, - }; - }), - ); - }), - subscribeToCurrentStreams: publicProcedure - .concat(createMediaServerIntegrationMiddleware("query")) - .input(z.object({ showOnlyPlaying: z.boolean() })) - .subscription(({ ctx, input }) => { - return observable<{ integrationId: string; data: StreamSession[] }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integration of ctx.integrations) { - const innerHandler = mediaServerRequestHandler.handler(integration, { - showOnlyPlaying: input.showOnlyPlaying, - }); - - const unsubscribe = innerHandler.subscribe((sessions) => { - emit.next({ - integrationId: integration.id, - data: sessions, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data } = await mediaServerRequestHandler + .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) + .getDataAsync(); + return { integrationId: integration.id, integrationKind: integration.kind, sessions: data }; }); }), }); diff --git a/packages/api/src/router/widgets/media-transcoding.ts b/packages/api/src/router/widgets/media-transcoding.ts index b02f0a714..88bd91704 100644 --- a/packages/api/src/router/widgets/media-transcoding.ts +++ b/packages/api/src/router/widgets/media-transcoding.ts @@ -1,7 +1,4 @@ -import { observable } from "@trpc/server/observable"; - import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { MediaTranscoding } from "@homarr/request-handler/media-transcoding"; import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding"; import { paginatedSchema } from "@homarr/validation/common"; @@ -21,26 +18,11 @@ export const mediaTranscodingRouter = createTRPCRouter({ pageOffset: (input.page - 1) * input.pageSize, pageSize: input.pageSize, }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getDataAsync(); return { integrationId: ctx.integration.id, data, }; }), - subscribeData: publicProcedure - .concat(createIndexerManagerIntegrationMiddleware("query")) - .input(paginatedSchema.pick({ page: true, pageSize: true })) - .subscription(({ ctx, input }) => { - return observable<{ integrationId: string; data: MediaTranscoding }>((emit) => { - const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, { - pageOffset: (input.page - 1) * input.pageSize, - pageSize: input.pageSize, - }); - const unsubscribe = innerHandler.subscribe((data) => { - emit.next({ integrationId: input.integrationId, data }); - }); - return unsubscribe; - }); - }), }); diff --git a/packages/api/src/router/widgets/minecraft.ts b/packages/api/src/router/widgets/minecraft.ts index 7f646c3bc..9cc5e25d7 100644 --- a/packages/api/src/router/widgets/minecraft.ts +++ b/packages/api/src/router/widgets/minecraft.ts @@ -1,7 +1,5 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; -import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status"; import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -16,21 +14,6 @@ export const minecraftRouter = createTRPCRouter({ isBedrockServer: input.isBedrockServer, domain: input.domain, }); - return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); - }), - subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => { - return observable((emit) => { - const innerHandler = minecraftServerStatusRequestHandler.handler({ - isBedrockServer: input.isBedrockServer, - domain: input.domain, - }); - const unsubscribe = innerHandler.subscribe((data) => { - emit.next(data); - }); - - return () => { - unsubscribe(); - }; - }); + return await innerHandler.getDataAsync(); }), }); diff --git a/packages/api/src/router/widgets/network-controller.ts b/packages/api/src/router/widgets/network-controller.ts index 053bd0dcb..61257d1ec 100644 --- a/packages/api/src/router/widgets/network-controller.ts +++ b/packages/api/src/router/widgets/network-controller.ts @@ -1,61 +1,26 @@ -import { observable } from "@trpc/server/observable"; - -import type { Modify } from "@homarr/common/types"; -import type { Integration } from "@homarr/db/schema"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { NetworkControllerSummary } from "@homarr/integrations/types"; import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const networkControllerRouter = createTRPCRouter({ summary: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController"))) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = networkControllerRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = networkControllerRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - }, - summary: data, - updatedAt: timestamp, - }; - }), - ); - return results; - }), - - subscribeToSummary: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController"))) - .subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - summary: NetworkControllerSummary; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = networkControllerRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((summary) => { - emit.next({ - integration, - summary, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + summary: data, + updatedAt: timestamp, }; }); }), diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index 89ec95165..bae8ba9fd 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -1,14 +1,9 @@ -import { observable } from "@trpc/server/observable"; - -import type { Modify } from "@homarr/common/types"; -import type { Integration } from "@homarr/db/schema"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { Notification } from "@homarr/integrations"; import { notificationsRequestHandler } from "@homarr/request-handler/notifications"; import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createNotificationsIntegrationMiddleware = (action: IntegrationAction) => @@ -18,46 +13,18 @@ export const notificationsRouter = createTRPCRouter({ getNotifications: publicProcedure .unstable_concat(createNotificationsIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = notificationsRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = notificationsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - data, - }; - }), - ); - }), - subscribeNotifications: publicProcedure - .unstable_concat(createNotificationsIntegrationMiddleware("query")) - .subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - data: Notification[]; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = notificationsRequestHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((data) => { - emit.next({ - integration, - data, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + data, }; }); }), diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts index 0fb81e72a..2a1bf40de 100644 --- a/packages/api/src/router/widgets/paperless-ngx.ts +++ b/packages/api/src/router/widgets/paperless-ngx.ts @@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const paperlessNgxRouter = createTRPCRouter({ getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => { const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {}); - const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const data = await innerHandler.getDataAsync(); return data.data; }), }); diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index 08d9ac270..b58fecd19 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -89,9 +89,7 @@ export const releasesRouter = createTRPCRouter({ providerUrl: repository.providerUrl, token: useToken ? tokensByProvider.get(repository.provider) : undefined, }) - .getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + .getDataAsync(); return { id: repositoryId, diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index ec9c223e0..5e43efe3e 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -1,4 +1,3 @@ -import { observable } from "@trpc/server/observable"; import SuperJSON from "superjson"; import { z } from "zod/v4"; @@ -6,12 +5,14 @@ import type { Session } from "@homarr/auth"; import type { Database } from "@homarr/db"; import { eq } from "@homarr/db"; import { items, users } from "@homarr/db/schema"; -import type { ExtendedFeedEntry } from "@homarr/request-handler/rss-feeds"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds"; import type { WidgetComponentProps } from "../../../../widgets/src"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const logger = createLogger({ module: "rssFeed" }); + const feedsInput = z.object({ urls: z.array(z.string()).max(100), maximumAmountPosts: z.number(), @@ -23,41 +24,28 @@ export const rssFeedRouter = createTRPCRouter({ ? input.urls : await restrictUrlsAsync(ctx.db, input.urls); - const rssFeeds = await Promise.all( + const settled = await Promise.allSettled( urls.map(async (url) => { const innerHandler = rssFeedsRequestHandler.handler({ url, count: input.maximumAmountPosts, }); - return await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + return await innerHandler.getDataAsync(); }), ); - return rssFeeds - .flatMap((rssFeed) => rssFeed.data.entries) - .slice(0, input.maximumAmountPosts) - .sort((entryA, entryB) => { + return settled + .flatMap((result, index) => { + if (result.status === "fulfilled") return result.value.data.entries; + logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason }); + return []; + }) + .toSorted((entryA, entryB) => { return entryA.published && entryB.published ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() : 0; - }); - }), - subscribeFeeds: publicProcedure.input(feedsInput).subscription(({ input }) => { - // It is not necessary to check access to all feeds here, as the client will only subscribe to feeds which already exist - return observable<{ url: string; entries: ExtendedFeedEntry[] }>((emit) => { - const unsubscribes = input.urls.map((url) => { - const innerHandler = rssFeedsRequestHandler.handler({ url, count: input.maximumAmountPosts }); - return innerHandler.subscribe((data) => { - emit.next({ url, entries: data.entries }); - }); - }); - - return () => { - unsubscribes.forEach((unsubscribe) => unsubscribe()); - }; - }); + }) + .slice(0, input.maximumAmountPosts); }), }); diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index dec3770a7..c37468752 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -1,4 +1,3 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; @@ -25,31 +24,9 @@ export const smartHomeRouter = createTRPCRouter({ .concat(createSmartHomeIntegrationMiddleware("query")) .query(async ({ ctx: { integration }, input }) => { const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + const { data } = await innerHandler.getDataAsync(); return data; }), - subscribeEntityState: publicProcedure - .concat(createSmartHomeIntegrationMiddleware("query")) - .input(z.object({ entityId: z.string() })) - .subscription(({ input, ctx }) => { - return observable<{ - entityId: string; - state: string; - }>((emit) => { - const innerHandler = smartHomeEntityStateRequestHandler.handler(ctx.integration, { - entityId: input.entityId, - }); - const unsubscribe = innerHandler.subscribe((state) => { - emit.next({ state, entityId: input.entityId }); - }); - - return () => { - unsubscribe(); - }; - }); - }), switchEntity: protectedProcedure .meta({ mcp: { @@ -64,9 +41,6 @@ export const smartHomeRouter = createTRPCRouter({ const client = await createIntegrationAsync(integration); const success = await client.triggerToggleAsync(input.entityId); - const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); - await innerHandler.invalidateAsync(); - return success; }), executeAutomation: protectedProcedure diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts index be4245921..01e7fb58b 100644 --- a/packages/api/src/router/widgets/speedtest-tracker.ts +++ b/packages/api/src/router/widgets/speedtest-tracker.ts @@ -1,54 +1,23 @@ -import { observable } from "@trpc/server/observable"; - -import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types"; import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const speedtestTrackerRouter = createTRPCRouter({ getDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "speedtestTracker")) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = speedtestTrackerRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = speedtestTrackerRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }), - ); - - return results; - }), - - subscribeToDashboard: publicProcedure - .concat(createManyIntegrationMiddleware("query", "speedtestTracker")) - .subscription(({ ctx }) => { - return observable<{ - integrationId: string; - dashboard: SpeedtestTrackerDashboardData; - timestamp: Date; - }>((emit) => { - const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = speedtestTrackerRequestHandler.handler(integration, {}); - return innerHandler.subscribe((dashboard) => { - emit.next({ - integrationId: integration.id, - dashboard, - timestamp: new Date(), - }); - }); - }); - - return () => { - unsubscribes.forEach((unsubscribe) => unsubscribe()); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, }; }); }), diff --git a/packages/api/src/router/widgets/stocks.ts b/packages/api/src/router/widgets/stocks.ts index d7b4ed13a..7fc08005b 100644 --- a/packages/api/src/router/widgets/stocks.ts +++ b/packages/api/src/router/widgets/stocks.ts @@ -18,6 +18,6 @@ export const stockPriceRouter = createTRPCRouter({ timeRange: input.timeRange, timeInterval: input.timeInterval, }); - return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await innerHandler.getDataAsync(); }), }); diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index 725667f68..ef60ce320 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -19,14 +19,7 @@ export const timetableRouter = createTRPCRouter({ }), ) .query(async ({ input }) => { - const innerHandler = timetableGetTimetableRequestHandler.handler({ - baseUrl: input.baseUrl, - stationId: input.stationId, - limit: input.limit, - }); - - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - + const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync(); return data; }), searchStations: publicProcedure @@ -37,13 +30,7 @@ export const timetableRouter = createTRPCRouter({ }), ) .query(async ({ input }) => { - const innerHandler = timetableSearchStationsRequestHandler.handler({ - baseUrl: input.baseUrl, - query: input.query, - }); - - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - + const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); return data; }), }); diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts index d46c80c66..5075c990b 100644 --- a/packages/api/src/router/widgets/tracearr.ts +++ b/packages/api/src/router/widgets/tracearr.ts @@ -1,48 +1,22 @@ -import { observable } from "@trpc/server/observable"; - -import type { TracearrDashboardData } from "@homarr/integrations/types"; import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const tracearrRouter = createTRPCRouter({ getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = tracearrRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = tracearrRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }), - ); - - return results; + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }); }), - subscribeToDashboard: publicProcedure - .concat(createManyIntegrationMiddleware("query", "tracearr")) - .subscription(({ ctx }) => { - return observable<{ integrationId: string; dashboard: TracearrDashboardData; timestamp: Date }>((emit) => { - const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = tracearrRequestHandler.handler(integration, {}); - return innerHandler.subscribe((dashboard) => { - emit.next({ - integrationId: integration.id, - dashboard, - timestamp: new Date(), - }); - }); - }); - - return () => { - unsubscribes.forEach((unsubscribe) => unsubscribe()); - }; - }); - }), }); diff --git a/packages/api/src/router/widgets/umami.ts b/packages/api/src/router/widgets/umami.ts index 2c240f4bb..3994f398e 100644 --- a/packages/api/src/router/widgets/umami.ts +++ b/packages/api/src/router/widgets/umami.ts @@ -1,4 +1,3 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -14,6 +13,7 @@ import { } from "@homarr/request-handler/umami"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "umami-router" }); @@ -39,26 +39,22 @@ export const umamiRouter = createTRPCRouter({ ) .concat(createManyIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = umamiRequestHandler.handler(integration, { - websiteId: input.websiteId, - timeFrame: input.timeFrame, - eventName: input.eventName, - }); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = umamiRequestHandler.handler(integration, { + websiteId: input.websiteId, + timeFrame: input.timeFrame, + eventName: input.eventName, + }); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - visitorStats: data, - updatedAt: timestamp, - }; - }), - ); - - return results; + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + visitorStats: data, + updatedAt: timestamp, + }; + }); }), getEventNames: publicProcedure @@ -67,7 +63,7 @@ export const umamiRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { try { const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getDataAsync(); return data; } catch (error) { logger.warn( @@ -93,7 +89,7 @@ export const umamiRouter = createTRPCRouter({ timeFrame: input.timeFrame, limit: input.limit, }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getDataAsync(); return data; } catch (error) { logger.warn( @@ -123,7 +119,7 @@ export const umamiRouter = createTRPCRouter({ timeFrame: input.timeFrame, limit: input.limit, }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getDataAsync(); return data; } catch (error) { logger.warn( @@ -148,13 +144,13 @@ export const umamiRouter = createTRPCRouter({ .concat(createOneIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { try { - const sortedNames = [...input.eventNames].sort(); + const sortedNames = [...input.eventNames].toSorted(); const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, { websiteId: input.websiteId, timeFrame: input.timeFrame, eventNames: sortedNames, }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getDataAsync(); return data; } catch (error) { logger.warn( @@ -176,7 +172,7 @@ export const umamiRouter = createTRPCRouter({ const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, { websiteId: input.websiteId, }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getDataAsync(); return data; } catch (error) { logger.warn( @@ -185,21 +181,4 @@ export const umamiRouter = createTRPCRouter({ return 0; } }), - - subscribeActiveVisitors: publicProcedure - .input(z.object({ websiteId: z.string() })) - .concat(createOneIntegrationMiddleware("query", "umami")) - .subscription(({ ctx, input }) => { - return observable((emit) => { - const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, { - websiteId: input.websiteId, - }); - const unsubscribe = innerHandler.subscribe((count) => { - emit.next(count); - }); - return () => { - unsubscribe(); - }; - }); - }), }); diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts index f1c27c434..16c6312dd 100644 --- a/packages/api/src/router/widgets/ups.ts +++ b/packages/api/src/router/widgets/ups.ts @@ -1,50 +1,24 @@ -import { observable } from "@trpc/server/observable"; - import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { UpsSummary } from "@homarr/integrations/types"; import { upsSummariesRequestHandler } from "@homarr/request-handler/ups"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const upsRouter = createTRPCRouter({ getSummaries: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = upsSummariesRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = upsSummariesRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - summaries: data, - updatedAt: timestamp, - }; - }), - ); - - return results; - }), - subscribeSummaries: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) - .subscription(({ ctx }) => { - return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => { - const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = upsSummariesRequestHandler.handler(integration, {}); - return innerHandler.subscribe((summaries) => { - emit.next({ - integrationId: integration.id, - summaries, - timestamp: new Date(), - }); - }); - }); - - return () => { - unsubscribes.forEach((unsubscribe) => unsubscribe()); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + summaries: data, + updatedAt: timestamp, }; }); }), diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index eb28f9e7e..6df14090c 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -1,54 +1,23 @@ -import { observable } from "@trpc/server/observable"; - -import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const uptimeKumaRouter = createTRPCRouter({ getDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) .query(async ({ ctx }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }), - ); - - return results; - }), - - subscribeToDashboard: publicProcedure - .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) - .subscription(({ ctx }) => { - return observable<{ - integrationId: string; - dashboard: UptimeKumaDashboardData; - timestamp: Date; - }>((emit) => { - const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); - return innerHandler.subscribe((dashboard) => { - emit.next({ - integrationId: integration.id, - dashboard, - timestamp: new Date(), - }); - }); - }); - - return () => { - unsubscribes.forEach((unsubscribe) => unsubscribe()); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, }; }); }), diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index f50d65f33..407e4986f 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -1,25 +1,16 @@ -import { observable } from "@trpc/server/observable"; - -import type { Modify } from "@homarr/common/types"; -import type { Integration } from "@homarr/db/schema"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { VpnSummary } from "@homarr/integrations/types"; import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const createVpnIntegrationMiddleware = () => - createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")); - export const vpnRouter = createTRPCRouter({ - getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => { - return await Promise.all( - ctx.integrations.map(async (integration) => { - const innerHandler = vpnSummaryHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - + getSummaries: publicProcedure + .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"))) + .query(async ({ ctx }) => { + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); return { integration: { id: integration.id, @@ -29,31 +20,6 @@ export const vpnRouter = createTRPCRouter({ }, summary: data, }; - }), - ); - }), - subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => { - return observable<{ - integration: Modify }>; - summary: VpnSummary | null; - }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integrationWithSecrets of ctx.integrations) { - const { decryptedSecrets: _, ...integration } = integrationWithSecrets; - const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {}); - const unsubscribe = innerHandler.subscribe((summary) => { - emit.next({ - integration, - summary, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }); - }), + }); + }), }); diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts index bb1077569..87ea6b48a 100644 --- a/packages/api/src/router/widgets/weather.ts +++ b/packages/api/src/router/widgets/weather.ts @@ -1,7 +1,5 @@ -import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; -import type { Weather } from "@homarr/request-handler/weather"; import { weatherRequestHandler } from "@homarr/request-handler/weather"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -14,16 +12,6 @@ const atLocationInput = z.object({ export const weatherRouter = createTRPCRouter({ atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => { const handler = weatherRequestHandler.handler(input); - return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data); - }), - subscribeAtLocation: publicProcedure.input(atLocationInput).subscription(({ input }) => { - return observable((emit) => { - const handler = weatherRequestHandler.handler(input); - const unsubscribe = handler.subscribe((data) => { - emit.next(data); - }); - - return unsubscribe; - }); + return await handler.getDataAsync().then((result) => result.data); }), }); diff --git a/packages/api/src/services/duckduckgo-bangs.ts b/packages/api/src/services/duckduckgo-bangs.ts index 297f5c8ed..48de45dd6 100644 --- a/packages/api/src/services/duckduckgo-bangs.ts +++ b/packages/api/src/services/duckduckgo-bangs.ts @@ -39,7 +39,7 @@ export const searchDuckDuckGoBangsAsync = async (input: { const queryTokenPrefix = normalizeBangToken(input.query); if (!queryTokenPrefix) return []; - const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({}); + const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getDataAsync(); const startIndex = lowerBound(allBangs, queryTokenPrefix); const matches: DuckDuckGoBang[] = []; diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts new file mode 100644 index 000000000..9ae1b8f04 --- /dev/null +++ b/packages/api/src/settle-integrations.ts @@ -0,0 +1,29 @@ +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; + +const logger = createLogger({ module: "settleIntegrations" }); + +interface IntegrationLike { + id: string; + name: string; + kind: string; +} + +export async function settleIntegrationQueries( + integrations: TIntegration[], + fn: (integration: TIntegration) => Promise, +): Promise { + const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); + return settled.flatMap((result, index) => { + if (result.status === "fulfilled") return [result.value]; + const integration = integrations[index]; + logger.warn( + new ErrorWithMetadata( + "Integration query failed", + { integrationId: integration?.id, integrationKind: integration?.kind }, + { cause: result.reason }, + ), + ); + return []; + }); +} diff --git a/packages/api/src/test/query-cache.spec.ts b/packages/api/src/test/query-cache.spec.ts index c765af031..090544cb2 100644 --- a/packages/api/src/test/query-cache.spec.ts +++ b/packages/api/src/test/query-cache.spec.ts @@ -10,59 +10,31 @@ import { isPersistableWidgetQueryKey, queryCacheDefaultStaleTimeMs, queryCacheMaxValueBytes, - queryCacheRetentionMs, } from "@homarr/api/query-cache"; import { queryCacheRouter } from "../router/query-cache"; const redisMock = vi.hoisted(() => { - const values = new Map(); - const calls: { - userId: string; - boardId: string; - key: string; - ttlMs: number; - maxValueBytes: number; - }[] = []; + const values = new Map(); return { values, - calls, - createQueryCacheChannel: vi.fn( - (options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => { - calls.push(options); - const storageKey = `${options.userId}:${options.boardId}:${options.key}`; - - return { - getAsync: async () => { - const item = values.get(storageKey); - if (!item) return null; - if (Date.now() > item.expiresAt) { - values.delete(storageKey); - return null; - } - - return item.value; - }, - setAsync: async (value: string) => { - if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) { - return false; - } - - values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs }); - return true; - }, - removeAsync: async () => { - values.delete(storageKey); - }, - }; + setQueryCacheAsync: vi.fn( + async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => { + if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false; + values.set(`${_userId}:${_boardId}`, value); + return true; }, ), + removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => { + values.delete(`${_userId}:${_boardId}`); + }), }; }); vi.mock("@homarr/redis", () => ({ - createQueryCacheChannel: redisMock.createQueryCacheChannel, + setQueryCacheAsync: redisMock.setQueryCacheAsync, + removeQueryCacheAsync: redisMock.removeQueryCacheAsync, })); vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); @@ -70,8 +42,8 @@ vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); afterEach(() => { vi.useRealTimers(); redisMock.values.clear(); - redisMock.calls.length = 0; - redisMock.createQueryCacheChannel.mockClear(); + redisMock.setQueryCacheAsync.mockClear(); + redisMock.removeQueryCacheAsync.mockClear(); }); const createSession = (userId = createId()): Session => ({ @@ -104,7 +76,7 @@ describe("isPersistableWidgetQueryKey", () => { }); describe("queryCacheRouter", () => { - test("stores, reads, and removes data for a board viewer", async () => { + test("stores and removes data for a board viewer", async () => { const db = createDb(); const session = createSession(); await insertUserAsync(db, session.user.id); @@ -112,14 +84,19 @@ describe("queryCacheRouter", () => { const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session }); - await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({ + await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({ stored: true, }); - await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value"); + expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith( + session.user.id, + "board-1", + "cached-value", + expect.any(Number), + queryCacheMaxValueBytes, + ); - await caller.removeItem({ boardId: "board-1", key: "query-key" }); - - await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull(); + await caller.removeItem({ boardId: "board-1" }); + expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1"); }); test("does not touch Redis when board access is denied", async () => { @@ -127,32 +104,32 @@ describe("queryCacheRouter", () => { await insertUserAsync(db, "owner"); await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false }); - redisMock.createQueryCacheChannel.mockClear(); + redisMock.setQueryCacheAsync.mockClear(); const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") }); - await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found"); - expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled(); + await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found"); + expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled(); }); - test("scopes anonymous public-board cache entries by anonymous user and board", async () => { + test("scopes anonymous public-board cache entries by anonymous user", async () => { const db = createDb(); await insertUserAsync(db, "owner"); await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true }); const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null }); - await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" }); + await caller.setItem({ boardId: "board-1", value: "cached-value" }); - expect(redisMock.calls.at(-1)).toMatchObject({ - userId: "anonymous", - boardId: "board-1", - key: "query-key", - ttlMs: queryCacheRetentionMs, - maxValueBytes: queryCacheMaxValueBytes, - }); + expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith( + "anonymous", + "board-1", + "cached-value", + expect.any(Number), + queryCacheMaxValueBytes, + ); }); - test("scopes signed-in cache entries by user and rejects oversized values", async () => { + test("rejects oversized values", async () => { const db = createDb(); const session = createSession("user-1"); await insertUserAsync(db, session.user.id); @@ -161,34 +138,9 @@ describe("queryCacheRouter", () => { const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session }); const value = "x".repeat(queryCacheMaxValueBytes + 1); - await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({ + await expect(caller.setItem({ boardId: "board-1", value })).resolves.toEqual({ stored: false, }); - expect(redisMock.calls.at(-1)).toMatchObject({ - userId: "user-1", - boardId: "board-1", - ttlMs: queryCacheRetentionMs, - maxValueBytes: queryCacheMaxValueBytes, - }); - }); - - test("expires values through the Redis TTL path", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - - const db = createDb(); - const session = createSession("user-1"); - await insertUserAsync(db, session.user.id); - await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false }); - - const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session }); - - await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" }); - await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value"); - - await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1); - - await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull(); }); }); diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx index c2ccc690a..b482fbe0c 100644 --- a/packages/boards/src/context.tsx +++ b/packages/boards/src/context.tsx @@ -20,16 +20,7 @@ export const BoardProvider = ({ }: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["getBoardByName"]; }>) => { - const { data } = clientApi.board.getBoardByName.useQuery( - { name: initialBoard.name }, - { - initialData: initialBoard, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - ); - - setActiveQueryCacheBoardId(data.id); + const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard }); // Update the board name so it can be used within updateBoard method updateBoardName(initialBoard.name); diff --git a/packages/common/src/date.ts b/packages/common/src/date.ts index de9ecadaf..5d46e45e1 100644 --- a/packages/common/src/date.ts +++ b/packages/common/src/date.ts @@ -17,6 +17,12 @@ export function formatDuration(milliseconds: number) { return `${minutes}:${String(seconds).padStart(2, "0")}`; } +export const toValidDate = (value: unknown): Date | null => { + if (value == null || typeof value === "boolean") return null; + const date = value instanceof Date ? value : new Date(value as string | number); + return Number.isFinite(date.getTime()) ? date : null; +}; + export const isDateWithin = (date: Date, relativeDate: string): boolean => { if (relativeDate.length < 2) { throw new Error("Relative date must be at least 2 characters long"); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 1f0cb8577..d9e42493e 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -1,32 +1,6 @@ import { analyticsJob } from "./jobs/analytics"; -import { dockerContainersJob } from "./jobs/docker"; import { iconsUpdaterJob } from "./jobs/icons-updater"; -import { dnsHoleJob } from "./jobs/integrations/dns-hole"; -import { downloadsJob } from "./jobs/integrations/downloads"; -import { - firewallCpuJob, - firewallInterfacesJob, - firewallMemoryJob, - firewallVersionJob, -} from "./jobs/integrations/firewall"; -import { healthMonitoringJob } from "./jobs/integrations/health-monitoring"; -import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; -import { indexerManagerJob } from "./jobs/integrations/indexer-manager"; -import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; -import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests"; -import { mediaServerJob } from "./jobs/integrations/media-server"; -import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding"; -import { networkControllerJob } from "./jobs/integrations/network-controller"; -import { refreshNotificationsJob } from "./jobs/integrations/notifications"; -import { timetableJob } from "./jobs/integrations/timetable"; -import { tracearrJob } from "./jobs/integrations/tracearr"; -import { umamiActiveVisitorsJob } from "./jobs/integrations/umami"; -import { vpnJob } from "./jobs/integrations/vpn"; -import { minecraftServerStatusJob } from "./jobs/minecraft-server-status"; import { pingJob } from "./jobs/ping"; -import { rssFeedsJob } from "./jobs/rss-feeds"; -import { updateCheckerJob } from "./jobs/update-checker"; -import { weatherJob } from "./jobs/weather"; import { createCronJobGroup } from "./lib"; const getJobGroup = () => { @@ -34,35 +8,9 @@ const getJobGroup = () => { analytics: analyticsJob, iconsUpdater: iconsUpdaterJob, ping: pingJob, - smartHomeEntityState: smartHomeEntityStateJob, - mediaServer: mediaServerJob, - mediaOrganizer: mediaOrganizerJob, - downloads: downloadsJob, - dnsHole: dnsHoleJob, - mediaRequestStats: mediaRequestStatsJob, - mediaRequestList: mediaRequestListJob, - rssFeeds: rssFeedsJob, - indexerManager: indexerManagerJob, - healthMonitoring: healthMonitoringJob, - updateChecker: updateCheckerJob, - mediaTranscoding: mediaTranscodingJob, - minecraftServerStatus: minecraftServerStatusJob, - dockerContainers: dockerContainersJob, - networkController: networkControllerJob, - firewallCpu: firewallCpuJob, - firewallMemory: firewallMemoryJob, - firewallVersion: firewallVersionJob, - firewallInterfaces: firewallInterfacesJob, - refreshNotifications: refreshNotificationsJob, - weather: weatherJob, - timetable: timetableJob, - tracearr: tracearrJob, - umamiActiveVisitors: umamiActiveVisitorsJob, - vpn: vpnJob, }); }; -// Add global type augmentation for homarr declare global { var cronJobs: ReturnType | undefined; } diff --git a/packages/cron-jobs/src/jobs/docker.ts b/packages/cron-jobs/src/jobs/docker.ts deleted file mode 100644 index 963f395bf..000000000 --- a/packages/cron-jobs/src/jobs/docker.ts +++ /dev/null @@ -1,31 +0,0 @@ -import SuperJSON from "superjson"; - -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { db, eq } from "@homarr/db"; -import { items } from "@homarr/db/schema"; -import { dockerContainersRequestHandler } from "@homarr/request-handler/docker"; - -import type { WidgetComponentProps } from "../../../widgets"; -import { createCronJob } from "../lib"; - -const logger = createLogger({ module: "dockerJobs" }); - -export const dockerContainersJob = createCronJob("dockerContainers", EVERY_MINUTE).withCallback(async () => { - const dockerItems = await db.query.items.findMany({ - where: eq(items.kind, "dockerContainers"), - }); - - await Promise.allSettled( - dockerItems.map(async (item) => { - try { - const options = SuperJSON.parse["options"]>(item.options); - const innerHandler = dockerContainersRequestHandler.handler(options); - await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); - } catch (error) { - logger.error(new ErrorWithMetadata("Failed to update Docker container status", { item }, { cause: error })); - } - }), - ); -}); diff --git a/packages/cron-jobs/src/jobs/integrations/dns-hole.ts b/packages/cron-jobs/src/jobs/integrations/dns-hole.ts deleted file mode 100644 index a78b28e08..000000000 --- a/packages/cron-jobs/src/jobs/integrations/dns-hole.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; - -import { createCronJob } from "../../lib"; - -export const dnsHoleJob = createCronJob("dnsHole", EVERY_MINUTE).withCallback( - createRequestIntegrationJobHandler(dnsHoleRequestHandler.handler, { - widgetKinds: ["dnsHoleSummary", "dnsHoleControls"], - getInput: { - dnsHoleSummary: () => ({}), - dnsHoleControls: () => ({}), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/downloads.ts b/packages/cron-jobs/src/jobs/integrations/downloads.ts deleted file mode 100644 index 54eb476dc..000000000 --- a/packages/cron-jobs/src/jobs/integrations/downloads.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; - -import { createCronJob } from "../../lib"; - -export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback( - createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, { - widgetKinds: ["downloads"], - getInput: { - downloads: (options) => ({ - limit: options.limitPerIntegration, - }), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/firewall.ts b/packages/cron-jobs/src/jobs/integrations/firewall.ts deleted file mode 100644 index 50637a642..000000000 --- a/packages/cron-jobs/src/jobs/integrations/firewall.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { EVERY_5_SECONDS, EVERY_30_SECONDS, EVERY_HOUR, EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { - firewallCpuRequestHandler, - firewallInterfacesRequestHandler, - firewallMemoryRequestHandler, - firewallVersionRequestHandler, -} from "@homarr/request-handler/firewall"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; - -import { createCronJob } from "../../lib"; - -export const firewallCpuJob = createCronJob("firewallCpu", EVERY_5_SECONDS).withCallback( - createRequestIntegrationJobHandler(firewallCpuRequestHandler.handler, { - widgetKinds: ["firewall"], - getInput: { - firewall: () => ({}), - }, - }), -); - -export const firewallMemoryJob = createCronJob("firewallMemory", EVERY_MINUTE).withCallback( - createRequestIntegrationJobHandler(firewallMemoryRequestHandler.handler, { - widgetKinds: ["firewall"], - getInput: { - firewall: () => ({}), - }, - }), -); - -export const firewallInterfacesJob = createCronJob("firewallInterfaces", EVERY_30_SECONDS).withCallback( - createRequestIntegrationJobHandler(firewallInterfacesRequestHandler.handler, { - widgetKinds: ["firewall"], - getInput: { - firewall: () => ({}), - }, - }), -); - -export const firewallVersionJob = createCronJob("firewallVersion", EVERY_HOUR).withCallback( - createRequestIntegrationJobHandler(firewallVersionRequestHandler.handler, { - widgetKinds: ["firewall"], - getInput: { - firewall: () => ({}), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts deleted file mode 100644 index 4d09801d2..000000000 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { coolifyRequestHandler } from "@homarr/request-handler/coolify"; -import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; - -import { createCronJob } from "../../lib"; - -export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback( - createRequestIntegrationJobHandler( - (integration, itemOptions: Record) => { - const { kind } = integration; - - if (kind === "beszel") { - return null as never; - } - if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") { - return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions); - } - if (kind === "coolify") { - return coolifyRequestHandler.handler({ ...integration, kind }, itemOptions); - } - return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions); - }, - { - widgetKinds: ["healthMonitoring", "systemResources", "coolify"], - getInput: { - healthMonitoring: () => ({}), - systemResources: () => ({}), - coolify: () => ({}), - }, - }, - ), -); diff --git a/packages/cron-jobs/src/jobs/integrations/home-assistant.ts b/packages/cron-jobs/src/jobs/integrations/home-assistant.ts deleted file mode 100644 index 2723391a7..000000000 --- a/packages/cron-jobs/src/jobs/integrations/home-assistant.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state"; - -import { createCronJob } from "../../lib"; - -export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback( - createRequestIntegrationJobHandler(smartHomeEntityStateRequestHandler.handler, { - widgetKinds: ["smartHome-entityState"], - getInput: { - "smartHome-entityState": (options) => ({ - entityId: options.entityId, - }), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts b/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts deleted file mode 100644 index 11da75508..000000000 --- a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; -import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; - -import { createCronJob } from "../../lib"; - -export const indexerManagerJob = createCronJob("indexerManager", EVERY_5_MINUTES).withCallback( - createRequestIntegrationJobHandler(indexerManagerRequestHandler.handler, { - widgetKinds: ["indexerManager"], - getInput: { - indexerManager: () => ({}), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts deleted file mode 100644 index a84696da9..000000000 --- a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts +++ /dev/null @@ -1,36 +0,0 @@ -import dayjs from "dayjs"; - -import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; - -import { createCronJob } from "../../lib"; - -export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback( - createRequestIntegrationJobHandler(calendarMonthRequestHandler.handler, { - widgetKinds: ["calendar"], - getInput: { - // Request handler will run for all specified months - calendar: (options) => { - const inputs = []; - - const startOffset = -Number(options.filterPastMonths); - const endOffset = Number(options.filterFutureMonths); - - for (let offsetMonths = startOffset; offsetMonths <= endOffset; offsetMonths++) { - const year = dayjs().subtract(offsetMonths, "months").year(); - const month = dayjs().subtract(offsetMonths, "months").month(); - - inputs.push({ - year, - month, - releaseType: options.releaseType, - showUnmonitored: options.showUnmonitored, - }); - } - - return inputs; - }, - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/media-requests.ts b/packages/cron-jobs/src/jobs/integrations/media-requests.ts deleted file mode 100644 index ededb6141..000000000 --- a/packages/cron-jobs/src/jobs/integrations/media-requests.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; -import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; - -import { createCronJob } from "../../lib"; - -export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MINUTE).withCallback( - createRequestIntegrationJobHandler(mediaRequestStatsRequestHandler.handler, { - widgetKinds: ["mediaRequests-requestStats"], - getInput: { - "mediaRequests-requestStats": () => ({}), - }, - }), -); - -export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback( - createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, { - widgetKinds: ["mediaRequests-requestList"], - getInput: { - "mediaRequests-requestList": () => ({}), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/media-server.ts b/packages/cron-jobs/src/jobs/integrations/media-server.ts deleted file mode 100644 index 099ea53ae..000000000 --- a/packages/cron-jobs/src/jobs/integrations/media-server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"; - -import { createCronJob } from "../../lib"; - -export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback( - createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, { - widgetKinds: ["mediaServer"], - getInput: { - mediaServer: ({ showOnlyPlaying }) => ({ showOnlyPlaying }), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts b/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts deleted file mode 100644 index c039dc250..000000000 --- a/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding"; - -import { createCronJob } from "../../lib"; - -export const mediaTranscodingJob = createCronJob("mediaTranscoding", EVERY_5_MINUTES).withCallback( - createRequestIntegrationJobHandler(mediaTranscodingRequestHandler.handler, { - widgetKinds: ["mediaTranscoding"], - getInput: { - mediaTranscoding: () => ({ pageOffset: 0, pageSize: 10 }), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/network-controller.ts b/packages/cron-jobs/src/jobs/integrations/network-controller.ts deleted file mode 100644 index 5d9354588..000000000 --- a/packages/cron-jobs/src/jobs/integrations/network-controller.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller"; - -import { createCronJob } from "../../lib"; - -export const networkControllerJob = createCronJob("networkController", EVERY_MINUTE).withCallback( - createRequestIntegrationJobHandler(networkControllerRequestHandler.handler, { - widgetKinds: ["networkControllerSummary"], - getInput: { - networkControllerSummary: () => ({}), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/notifications.ts b/packages/cron-jobs/src/jobs/integrations/notifications.ts deleted file mode 100644 index 0f544995f..000000000 --- a/packages/cron-jobs/src/jobs/integrations/notifications.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -import { notificationsRequestHandler } from "@homarr/request-handler/notifications"; - -import { createCronJob } from "../../lib"; - -export const refreshNotificationsJob = createCronJob("refreshNotifications", EVERY_5_MINUTES).withCallback( - createRequestIntegrationJobHandler(notificationsRequestHandler.handler, { - widgetKinds: ["notifications"], - getInput: { - notifications: (options) => options, - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/timetable.ts b/packages/cron-jobs/src/jobs/integrations/timetable.ts deleted file mode 100644 index 8cda973e3..000000000 --- a/packages/cron-jobs/src/jobs/integrations/timetable.ts +++ /dev/null @@ -1,39 +0,0 @@ -import SuperJSON from "superjson"; - -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; -import { db, eq } from "@homarr/db"; -import { items } from "@homarr/db/schema"; -import { timetableGetTimetableRequestHandler } from "@homarr/request-handler/timetable"; - -import type { WidgetComponentProps } from "../../../../widgets"; -import { createCronJob } from "../../lib"; - -const logger = createLogger({ module: "timetableJobs" }); - -export const timetableJob = createCronJob("timetable", EVERY_5_MINUTES).withCallback(async () => { - const timetableItems = await db.query.items.findMany({ - where: eq(items.kind, "timetable"), - }); - - const parsedItems = timetableItems.map((item) => ({ - id: item.id, - options: SuperJSON.parse["options"]>(item.options), - })); - - for (const item of parsedItems) { - if (!item.options.station) continue; - - try { - const innerHandler = timetableGetTimetableRequestHandler.handler({ - baseUrl: item.options.baseUrl, - stationId: item.options.station.value, - limit: 10, - }); - await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); - } catch (error) { - logger.error(new ErrorWithMetadata("Failed to update timetable", { id: item.id }, { cause: error })); - } - } -}); diff --git a/packages/cron-jobs/src/jobs/integrations/tracearr.ts b/packages/cron-jobs/src/jobs/integrations/tracearr.ts deleted file mode 100644 index f80130374..000000000 --- a/packages/cron-jobs/src/jobs/integrations/tracearr.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; - -import { createCronJob } from "../../lib"; - -export const tracearrJob = createCronJob("tracearr", EVERY_5_SECONDS).withCallback( - createRequestIntegrationJobHandler(tracearrRequestHandler.handler, { - widgetKinds: ["tracearr"], - getInput: { - tracearr: () => ({}), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/umami.ts b/packages/cron-jobs/src/jobs/integrations/umami.ts deleted file mode 100644 index c50a3c604..000000000 --- a/packages/cron-jobs/src/jobs/integrations/umami.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; -import { umamiActiveVisitorsRequestHandler } from "@homarr/request-handler/umami"; - -import { createCronJob } from "../../lib"; - -export const umamiActiveVisitorsJob = createCronJob("umamiActiveVisitors", EVERY_30_SECONDS).withCallback( - createRequestIntegrationJobHandler(umamiActiveVisitorsRequestHandler.handler, { - widgetKinds: ["umami"], - getInput: { - umami: (options) => (options.websiteId ? { websiteId: options.websiteId } : []), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/integrations/vpn.ts b/packages/cron-jobs/src/jobs/integrations/vpn.ts deleted file mode 100644 index b6495b359..000000000 --- a/packages/cron-jobs/src/jobs/integrations/vpn.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; - -import { createCronJob } from "../../lib"; - -export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback( - createRequestIntegrationJobHandler(vpnSummaryHandler.handler, { - widgetKinds: ["vpn"], - getInput: { - vpn: () => ({}), - }, - }), -); diff --git a/packages/cron-jobs/src/jobs/minecraft-server-status.ts b/packages/cron-jobs/src/jobs/minecraft-server-status.ts deleted file mode 100644 index e6c084702..000000000 --- a/packages/cron-jobs/src/jobs/minecraft-server-status.ts +++ /dev/null @@ -1,25 +0,0 @@ -import SuperJSON from "superjson"; - -import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; -import { db, eq } from "@homarr/db"; -import { items } from "@homarr/db/schema"; -import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status"; - -import type { WidgetComponentProps } from "../../../widgets/src"; -import { createCronJob } from "../lib"; - -export const minecraftServerStatusJob = createCronJob("minecraftServerStatus", EVERY_5_MINUTES).withCallback( - async () => { - const dbItems = await db.query.items.findMany({ - where: eq(items.kind, "minecraftServerStatus"), - }); - - await Promise.allSettled( - dbItems.map(async (item) => { - const options = SuperJSON.parse["options"]>(item.options); - const innerHandler = minecraftServerStatusRequestHandler.handler(options); - await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); - }), - ); - }, -); diff --git a/packages/cron-jobs/src/jobs/rss-feeds.ts b/packages/cron-jobs/src/jobs/rss-feeds.ts deleted file mode 100644 index 6a497a043..000000000 --- a/packages/cron-jobs/src/jobs/rss-feeds.ts +++ /dev/null @@ -1,39 +0,0 @@ -import SuperJSON from "superjson"; - -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions"; -import { db, eq } from "@homarr/db"; -import { items } from "@homarr/db/schema"; -import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds"; - -// This import is done that way to avoid circular dependencies. -import type { WidgetComponentProps } from "../../../widgets"; -import { createCronJob } from "../lib"; - -const logger = createLogger({ module: "rssFeedsJobs" }); - -export const rssFeedsJob = createCronJob("rssFeeds", EVERY_10_MINUTES).withCallback(async () => { - const rssItems = await db.query.items.findMany({ - where: eq(items.kind, "rssFeed"), - }); - - const itemOptions = rssItems.map((item) => SuperJSON.parse["options"]>(item.options)); - - for (const option of itemOptions) { - const maxAmountPosts = typeof option.maximumAmountPosts === "number" ? option.maximumAmountPosts : 100; - for (const url of option.feedUrls) { - try { - const innerHandler = rssFeedsRequestHandler.handler({ - url, - count: maxAmountPosts, - }); - await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: true, - }); - } catch (error) { - logger.error(new ErrorWithMetadata("Failed to update RSS feed", { url }, { cause: error })); - } - } - } -}); diff --git a/packages/cron-jobs/src/jobs/update-checker.ts b/packages/cron-jobs/src/jobs/update-checker.ts deleted file mode 100644 index eea68d07b..000000000 --- a/packages/cron-jobs/src/jobs/update-checker.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { EVERY_DAY } from "@homarr/cron-jobs-core/expressions"; -import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker"; - -import { createCronJob } from "../lib"; - -export const updateCheckerJob = createCronJob("updateChecker", EVERY_DAY, { - runOnStart: true, -}).withCallback(async () => { - const handler = updateCheckerRequestHandler.handler({}); - await handler.getCachedOrUpdatedDataAsync({}); -}); diff --git a/packages/cron-jobs/src/jobs/weather.ts b/packages/cron-jobs/src/jobs/weather.ts deleted file mode 100644 index 1b23ade4c..000000000 --- a/packages/cron-jobs/src/jobs/weather.ts +++ /dev/null @@ -1,36 +0,0 @@ -import SuperJSON from "superjson"; - -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions"; -import { db, eq } from "@homarr/db"; -import { items } from "@homarr/db/schema"; -import { weatherRequestHandler } from "@homarr/request-handler/weather"; - -import type { WidgetComponentProps } from "../../../widgets"; -import { createCronJob } from "../lib"; - -const logger = createLogger({ module: "weatherJobs" }); - -export const weatherJob = createCronJob("weather", EVERY_10_MINUTES).withCallback(async () => { - const weatherItems = await db.query.items.findMany({ - where: eq(items.kind, "weather"), - }); - - const parsedItems = weatherItems.map((item) => ({ - id: item.id, - options: SuperJSON.parse["options"]>(item.options), - })); - - for (const item of parsedItems) { - try { - const innerHandler = weatherRequestHandler.handler({ - longitude: item.options.location.longitude, - latitude: item.options.location.latitude, - }); - await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); - } catch (error) { - logger.error(new ErrorWithMetadata("Failed to update weather", { id: item.id }, { cause: error })); - } - } -}); diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx index 43006ae60..058e75bd0 100644 --- a/packages/forms-collection/src/icon-picker/icon-picker.tsx +++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx @@ -55,8 +55,6 @@ export const IconPicker = ({ const [value, setValue] = useUncontrolled({ value: propsValue, onChange }); const [query, setQuery] = useState(""); - // ponytail: reset the typed query when the committed value changes - // externally (form reset, parent re-render) so the input reverts to the URL. useEffect(() => { setQuery(""); }, [value]); @@ -65,13 +63,8 @@ export const IconPicker = ({ const tCommon = useScopedI18n("common"); const [debouncedQuery] = useDebouncedValue(query, 100); - // ponytail: only search when the typed query differs from the committed - // value. On mount the input shows the existing URL (edit mode), which - // would never match the bare filenames in the icon table. const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || ""; - // ponytail: not useSuspenseQuery — an above Suspense boundary would - // trigger on every keystroke and the dropdown flash is bad UX. const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery( { searchText }, { placeholderData: (prev) => prev }, @@ -123,11 +116,8 @@ export const IconPicker = ({ ); }); - // ponytail: show the typed query if non-empty, else fall back to the URL. const inputValue = query || value || ""; - // ponytail: show the existing icon as a left-section preview. const leftSection = shouldShowPreview(value) && ; - // ponytail: prop override -> real count -> loading text. const placeholderText = placeholder || (data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) || diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index bf6f3640c..186ed2203 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -13,13 +13,11 @@ import type { BeszelAlertHistory, BeszelAuthResponse, BeszelContainer, - BeszelContainerStats, BeszelContainerStatsRecord, BeszelSmartDevice, BeszelSystem, BeszelSystemDetails, BeszelSystemdService, - BeszelSystemStats, BeszelSystemStatsRecord, CreateAlertInput, PocketBaseListResponse, @@ -286,111 +284,6 @@ export class BeszelIntegration extends Integration { }); } - public async subscribeRealtimeMetrics( - systemId: string, - onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void, - signal: AbortSignal, - ): Promise { - const session = await this.authenticateAsync(); - const sseUrl = this.url("/api/realtime"); - logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId }); - - const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, { - headers: { Authorization: session.token }, - signal, - }); - - if (!sseResponse.ok || !sseResponse.body) { - logger.warn("Beszel SSE connection failed", { - integrationId: this.integration.id, - systemId, - status: sseResponse.status, - }); - throw new ResponseError(sseResponse); - } - logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId }); - - const reader = sseResponse.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let clientId: string | null = null; - - const processLine = (line: string) => { - if (!line.startsWith("data:")) return; - const jsonStr = line.slice(5).trim(); - if (!jsonStr) return; - - try { - const parsed = JSON.parse(jsonStr) as Record; - - if (!clientId && typeof parsed.clientId === "string") { - clientId = parsed.clientId; - this.subscribeTopic(clientId, systemId, session.token).catch((err) => { - logger.warn("Failed to subscribe to rt_metrics topic, aborting SSE", { error: err }); - reader.cancel().catch(() => {}); - }); - return; - } - - if ("stats" in parsed) { - const now = new Date().toISOString(); - const envelope = { id: "", system: systemId, type: "1m" as const, created: now, updated: now }; - const stats: BeszelSystemStatsRecord = { ...envelope, stats: parsed.stats as BeszelSystemStats }; - - let containerStats: BeszelContainerStatsRecord | null = null; - const hasContainers = - parsed.container && - Array.isArray(parsed.container) && - (parsed.container as BeszelContainerStats[]).length > 0; - if (hasContainers) { - containerStats = { ...envelope, stats: parsed.container as BeszelContainerStats[] }; - } - onMessage({ stats, containerStats }); - } - } catch { - logger.debug("Failed to parse SSE message", { line }); - } - }; - - try { - while (!signal.aborted) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - processLine(line); - } - } - if (!signal.aborted) { - logger.warn("Beszel SSE stream ended unexpectedly", { - integrationId: this.integration.id, - systemId, - }); - throw new Error("Beszel SSE stream ended unexpectedly"); - } - } finally { - reader.cancel().catch(() => {}); - } - } - - private async subscribeTopic(clientId: string, systemId: string, token: string): Promise { - const topic = `rt_metrics?options=${JSON.stringify({ query: { system: systemId } })}`; - const response = await fetchWithTrustedCertificatesAsync(this.url("/api/realtime"), { - method: "POST", - headers: { - Authorization: token, - "Content-Type": "application/json", - }, - body: JSON.stringify({ clientId, subscriptions: [topic] }), - }); - if (!response.ok) { - throw new ResponseError(response); - } - } - protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), { method: "POST", diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 6c99c11b6..02258a2e0 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -1,42 +1,22 @@ import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; -import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel"; +import { createListChannel, createSubPubChannel } from "./lib/channel"; export { - createCacheChannel, - createItemAndIntegrationChannel, - createItemChannel, - createIntegrationOptionsChannel, - createWidgetOptionsChannel, - createChannelWithLatestAndEvents, - createChannelEventHistory, handshakeAsync, createSubPubChannel, createGetSetChannel, - createQueryCacheChannel, - getAllQueryCacheAsync, + setQueryCacheAsync, + getQueryCacheAsync, + removeQueryCacheAsync, invalidateIntegrationCacheAsync, } from "./lib/channel"; -export { createIntegrationHistoryChannel } from "./lib/channels/history-channel"; - -export const exampleChannel = createSubPubChannel<{ message: string }>("example"); export const pingChannel = createSubPubChannel< { url: string; statusCode: number; durationMs: number } | { url: string; error: string } >("ping"); export const pingUrlChannel = createListChannel("ping-url"); -export const homeAssistantEntityState = createSubPubChannel<{ - entityId: string; - state: string; -}>("home-assistant/entity-state"); - -export const queueChannel = createQueueChannel<{ - name: string; - executionDate: Date; - data: unknown; -}>("common-queue"); - export interface LoggerMessage { message: string; level: LogLevel; diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index d04d1499a..1b1e582c1 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -1,8 +1,7 @@ import superjson from "superjson"; -import { createId, hashObjectBase64 } from "@homarr/common"; +import { createId } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import type { WidgetKind } from "@homarr/definitions"; import { ChannelSubscriptionTracker } from "./channel-subscription-tracker"; import { createRedisConnection } from "./connection"; @@ -129,218 +128,38 @@ export const createGetSetChannel = (name: string) => { }; }; -interface QueryCacheChannelOptions { - userId: string; - boardId: string; - key: string; - ttlMs: number; - maxValueBytes: number; -} +const queryCacheKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`; -const queryCacheHashKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`; - -export const createQueryCacheChannel = ({ userId, boardId, key, ttlMs, maxValueBytes }: QueryCacheChannelOptions) => { - const hkey = queryCacheHashKey(userId, boardId); - - return { - name: `${hkey}:${key}`, - getAsync: async () => { - return await getSetClient.hget(hkey, key); - }, - setAsync: async (value: string) => { - if (Buffer.byteLength(value, "utf8") > maxValueBytes) { - logger.warn("Query cache value exceeded maximum size", { - channel: hkey, - valueBytes: Buffer.byteLength(value, "utf8"), - maxValueBytes, - }); - return false; - } - - await getSetClient.hset(hkey, key, value); - await getSetClient.pexpire(hkey, ttlMs); - return true; - }, - removeAsync: async () => { - await getSetClient.hdel(hkey, key); - }, - }; -}; - -export const getAllQueryCacheAsync = async (userId: string, boardId: string): Promise> => { - const hkey = queryCacheHashKey(userId, boardId); - return await getSetClient.hgetall(hkey); -}; - -/** - * Creates a new cache channel. - * @param name name of the channel - * @param cacheDurationMs duration in milliseconds to cache - * @returns cache channel object - */ -export const createCacheChannel = (name: string, cacheDurationMs: number = 5 * 60 * 1000) => { - const cacheChannelName = `cache:${name}`; - - return { - /** - * Get the data from the cache channel. - * @returns data or null if not found or expired - */ - getAsync: async () => { - const data = await getSetClient.get(cacheChannelName); - if (!data) return null; - - const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data); - const now = new Date(); - const diff = now.getTime() - parsedData.timestamp.getTime(); - if (diff > cacheDurationMs) return null; - - return parsedData; - }, - /** - * Consume the data from the cache channel, if not present or expired, it will call the callback to get new data. - * @param callback callback function to get new data if not present or expired - * @returns data or new data if not present or expired - */ - consumeAsync: async (callback: () => Promise) => { - const data = await getSetClient.get(cacheChannelName); - - const getNewDataAsync = async () => { - logger.debug(`Cache miss for channel '${cacheChannelName}'`); - const newData = await callback(); - const result = { data: newData, timestamp: new Date() }; - await getSetClient.set(cacheChannelName, superjson.stringify(result)); - logger.debug(`Cache updated for channel '${cacheChannelName}'`); - return result; - }; - - if (!data) { - return await getNewDataAsync(); - } - - const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data); - const now = new Date(); - const diff = now.getTime() - parsedData.timestamp.getTime(); - - if (diff > cacheDurationMs) { - return await getNewDataAsync(); - } - - logger.debug(`Cache hit for channel '${cacheChannelName}'`); - - return parsedData; - }, - /** - * Invalidate the cache channels data. - */ - invalidateAsync: async () => { - await getSetClient.del(cacheChannelName); - }, - /** - * Set the data in the cache channel. - * @param data data to be stored in the cache channel - */ - setAsync: async (data: TData) => { - await getSetClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() })); - }, - }; -}; - -export const createItemAndIntegrationChannel = (kind: WidgetKind, integrationId: string) => { - const channelName = `item:${kind}:integration:${integrationId}`; - return createChannelWithLatestAndEvents(channelName); -}; - -export const createIntegrationOptionsChannel = ( - integrationId: string, - queryKey: string, - options: Record, +export const setQueryCacheAsync = async ( + userId: string, + boardId: string, + value: string, + ttlMs: number, + maxValueBytes: number, ) => { - const optionsKey = hashObjectBase64(options); - const channelName = `integration:${integrationId}:${queryKey}:options:${optionsKey}`; - return createChannelWithLatestAndEvents(channelName); + if (Buffer.byteLength(value, "utf8") > maxValueBytes) { + logger.warn("Query cache value exceeded maximum size", { + key: queryCacheKey(userId, boardId), + valueBytes: Buffer.byteLength(value, "utf8"), + maxValueBytes, + }); + return false; + } + await getSetClient.set(queryCacheKey(userId, boardId), value, "PX", ttlMs); + return true; +}; + +export const getQueryCacheAsync = async (userId: string, boardId: string) => + await getSetClient.get(queryCacheKey(userId, boardId)); + +export const removeQueryCacheAsync = async (userId: string, boardId: string) => { + await getSetClient.del(queryCacheKey(userId, boardId)); }; -/** - * Invalidates all cached data for a given integration by deleting every Redis key - * that starts with `integration:${integrationId}:` as well as the integration's - * session store entry. This ensures that stale data from a misconfigured or - * updated integration is never served from cache. - */ export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => { const client = getSetClient as typeof getSetClient | null; - if (!client) { - return; - } - - const patterns = [`integration:${integrationId}:*`, `session-store:${integrationId}`]; - let deletedCount = 0; - for (const pattern of patterns) { - let cursor = "0"; - do { - const [nextCursor, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200); - cursor = nextCursor; - if (keys.length > 0) { - await client.del(...keys); - deletedCount += keys.length; - } - } while (cursor !== "0"); - } - if (deletedCount > 0) { - logger.info("Invalidated integration cache", { integrationId, deletedKeys: deletedCount }); - } -}; - -export const createWidgetOptionsChannel = ( - widgetKind: WidgetKind, - queryKey: string, - options: Record, -) => { - const optionsKey = hashObjectBase64(options); - const channelName = `widget:${widgetKind}:${queryKey}:options:${optionsKey}`; - return createChannelWithLatestAndEvents(channelName); -}; - -export const createItemChannel = (itemId: string) => { - return createChannelWithLatestAndEvents(`item:${itemId}`); -}; - -export const createChannelEventHistory = (channelName: string, maxElements = 32) => { - return { - subscribe: (callback: (data: TData) => void) => { - return ChannelSubscriptionTracker.subscribe(channelName, (message) => { - callback(superjson.parse(message)); - }); - }, - pushAsync: async (data: TData, options = { publish: false }) => { - if (options.publish) await publisher.publish(channelName, superjson.stringify(data)); - await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); - await getSetClient.ltrim(channelName, 0, maxElements); - }, - clearAsync: async () => { - await getSetClient.del(channelName); - }, - /** - * Returns a slice of the available data in the channel. - * If any of the indexes are out of range (or -range), returned data will be clamped. - * @param startIndex Start index of the slice, negative values are counted from the end, defaults at beginning of range. - * @param endIndex End index of the slice, negative values are counted from the end, defaults at end of range. - */ - getSliceAsync: async (startIndex = 0, endIndex = -1) => { - const range = await getSetClient.lrange(channelName, startIndex, endIndex); - return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item)); - }, - getSliceUntilTimeAsync: async (time: Date) => { - const itemsInCollection = await getSetClient.lrange(channelName, 0, -1); - return itemsInCollection - .map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item)) - .filter((item) => item.timestamp < time); - }, - getLengthAsync: async () => { - return await getSetClient.llen(channelName); - }, - name: channelName, - }; + if (!client) return; + await client.del(`session-store:${integrationId}`); }; /** @@ -407,40 +226,6 @@ export const createChannelEventHistoryOld = (channelName: string, maxElem }; }; -export const createChannelWithLatestAndEvents = (channelName: string) => { - return { - subscribe: (callback: (data: TData) => void) => { - return ChannelSubscriptionTracker.subscribe(channelName, (message) => { - callback(superjson.parse(message)); - }); - }, - publishAndUpdateLastStateAsync: async (data: TData) => { - // Set first, then publish. If publish fails the storage is still consistent - // and subscribers will catch up on the next event. - const payload = superjson.stringify({ data, timestamp: new Date() }); - await getSetClient.set(channelName, payload); - try { - await publisher.publish(channelName, superjson.stringify(data)); - } catch (error) { - logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", { - channel: channelName, - error: error instanceof Error ? error.message : String(error), - }); - } - }, - setAsync: async (data: TData) => { - await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() })); - }, - getAsync: async () => { - const data = await getSetClient.get(channelName); - if (!data) return null; - - return superjson.parse<{ data: TData; timestamp: Date }>(data); - }, - name: channelName, - }; -}; - const queueClient = createRedisConnection(); type WithId = TItem & { _id: string }; diff --git a/packages/redis/src/lib/channels/history-channel.ts b/packages/redis/src/lib/channels/history-channel.ts deleted file mode 100644 index 541830464..000000000 --- a/packages/redis/src/lib/channels/history-channel.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createChannelEventHistory } from "../channel"; - -export const createIntegrationHistoryChannel = (integrationId: string, queryKey: string, maxElements = 32) => { - const channelName = `integration:${integrationId}:history:${queryKey}`; - return createChannelEventHistory(channelName, maxElements); -}; diff --git a/packages/request-handler/src/anchor-notes.ts b/packages/request-handler/src/anchor-notes.ts index bd6f19e44..75677b8ba 100644 --- a/packages/request-handler/src/anchor-notes.ts +++ b/packages/request-handler/src/anchor-notes.ts @@ -1,30 +1,22 @@ -import dayjs from "dayjs"; - import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const anchorNotesListRequestHandler = createCachedIntegrationRequestHandler< +export const anchorNotesListRequestHandler = createIntegrationRequestHandler< AnchorNoteSummary[], "anchor", AnchorNotesListInput >({ - queryKey: "anchorNotesList", - cacheDuration: dayjs.duration(15, "seconds"), async requestAsync(integration, input) { const instance = await createIntegrationAsync(integration); return instance.listNotesAsync(input); }, }); -export const anchorNoteRequestHandler = createCachedIntegrationRequestHandler( - { - queryKey: "anchorNote", - cacheDuration: dayjs.duration(15, "seconds"), - async requestAsync(integration, input) { - const instance = await createIntegrationAsync(integration); - return instance.getNoteAsync(input.noteId); - }, +export const anchorNoteRequestHandler = createIntegrationRequestHandler({ + async requestAsync(integration, input) { + const instance = await createIntegrationAsync(integration); + return instance.getNoteAsync(input.noteId); }, -); +}); diff --git a/packages/request-handler/src/archive-team-warrior.ts b/packages/request-handler/src/archive-team-warrior.ts index 4038ec9dc..e02391f19 100644 --- a/packages/request-handler/src/archive-team-warrior.ts +++ b/packages/request-handler/src/archive-team-warrior.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { ArchiveTeamWarriorStatus } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler< +export const archiveTeamWarriorRequestHandler = createIntegrationRequestHandler< ArchiveTeamWarriorStatus, IntegrationKindByCategory<"archiving">, Record @@ -15,6 +13,4 @@ export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHa const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getStatusAsync(); }, - cacheDuration: dayjs.duration(5, "minute"), - queryKey: "archiveTeamWarriorStatus", }); diff --git a/packages/request-handler/src/audiobookshelf.ts b/packages/request-handler/src/audiobookshelf.ts index bb4b2d83c..12562342c 100644 --- a/packages/request-handler/src/audiobookshelf.ts +++ b/packages/request-handler/src/audiobookshelf.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import { createIntegrationAsync } from "@homarr/integrations"; import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandler< +export const audiobookshelfRequestHandler = createIntegrationRequestHandler< AudiobookshelfDashboardData, "audiobookshelf", Record @@ -14,6 +12,4 @@ export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandle const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); }, - cacheDuration: dayjs.duration(10, "minutes"), - queryKey: "audiobookshelfDashboard", }); diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts index 6dbe20ae0..4a60a3a3a 100644 --- a/packages/request-handler/src/beszel.ts +++ b/packages/request-handler/src/beszel.ts @@ -1,5 +1,3 @@ -import dayjs from "dayjs"; - import { createLogger } from "@homarr/core/infrastructure/logs"; import { createIntegrationAsync } from "@homarr/integrations"; import type { @@ -12,7 +10,7 @@ import type { BeszelSystemStatsRecord, } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; const logger = createLogger({ module: "beszelRequestHandler" }); @@ -53,7 +51,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul }; } -export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler< +export const beszelSystemsRequestHandler = createIntegrationRequestHandler< BeszelSystemRow[], "beszel" | "mock", Record @@ -64,7 +62,10 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler const systems = await instance.getSystemsAsync(); const enriched = await Promise.all( systems.map(async (system) => { - const details = await instance.getSystemDetailsAsync(system.id).catch(() => null); + const details = await instance.getSystemDetailsAsync(system.id).catch((error) => { + logger.warn("Failed to fetch Beszel system details", { systemId: system.id, error: String(error) }); + return null; + }); return mapToSystemRow(system, details); }), ); @@ -75,10 +76,6 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler }); return enriched; }, - cacheDuration: dayjs.duration(1, "second"), - fallbackToStaleOnError: true, - retry: { attempts: 2, delayMs: 500 }, - queryKey: "beszelSystems", }); export interface BeszelAlertsData { @@ -86,7 +83,7 @@ export interface BeszelAlertsData { history: BeszelAlertHistory[]; } -export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler< +export const beszelAlertsRequestHandler = createIntegrationRequestHandler< BeszelAlertsData, "beszel" | "mock", { includeHistory: boolean; maxHistoryItems: number } @@ -104,10 +101,6 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler< }); return { alerts, history }; }, - cacheDuration: dayjs.duration(1, "second"), - fallbackToStaleOnError: true, - retry: { attempts: 2, delayMs: 500 }, - queryKey: "beszelAlerts", }); const timePeriodConfig: Record = { @@ -124,7 +117,7 @@ export interface BeszelStatsData { containerStats: BeszelContainerStatsRecord[]; } -export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< +export const beszelStatsRequestHandler = createIntegrationRequestHandler< BeszelStatsData, "beszel" | "mock", { systemId: string; timePeriod: string; includeDocker: boolean } @@ -135,7 +128,10 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< const instance = await createIntegrationAsync(integration); const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage); const containerStats = input.includeDocker - ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => []) + ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch((error) => { + logger.warn("Failed to fetch Beszel container stats", { systemId: input.systemId, error: String(error) }); + return []; + }) : []; logger.debug("beszelStats fetch completed", { integrationId: integration.id, @@ -147,8 +143,4 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< }); return { systemStats, containerStats }; }, - cacheDuration: dayjs.duration(1, "second"), - fallbackToStaleOnError: true, - retry: { attempts: 2, delayMs: 500 }, - queryKey: "beszelStats", }); diff --git a/packages/request-handler/src/calendar.ts b/packages/request-handler/src/calendar.ts index dd198912a..a358d13e8 100644 --- a/packages/request-handler/src/calendar.ts +++ b/packages/request-handler/src/calendar.ts @@ -4,9 +4,9 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler< +export const calendarMonthRequestHandler = createIntegrationRequestHandler< CalendarEvent[], IntegrationKindByCategory<"calendar">, { year: number; month: number; releaseType: RadarrReleaseType[]; showUnmonitored: boolean } @@ -23,6 +23,4 @@ export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler input.showUnmonitored, ); }, - cacheDuration: dayjs.duration(1, "minute"), - queryKey: "calendarMonth", }); diff --git a/packages/request-handler/src/coolify.ts b/packages/request-handler/src/coolify.ts index d307a780f..25ddfe962 100644 --- a/packages/request-handler/src/coolify.ts +++ b/packages/request-handler/src/coolify.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import { createIntegrationAsync } from "@homarr/integrations"; import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const coolifyRequestHandler = createCachedIntegrationRequestHandler< +export const coolifyRequestHandler = createIntegrationRequestHandler< CoolifyInstanceInfo, "coolify", Record @@ -14,6 +12,4 @@ export const coolifyRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getInstanceInfoAsync(); }, - cacheDuration: dayjs.duration(30, "seconds"), - queryKey: "coolifyInfo", }); diff --git a/packages/request-handler/src/dns-hole.ts b/packages/request-handler/src/dns-hole.ts index a8c920801..85188c740 100644 --- a/packages/request-handler/src/dns-hole.ts +++ b/packages/request-handler/src/dns-hole.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { DnsHoleSummary } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler< +export const dnsHoleRequestHandler = createIntegrationRequestHandler< DnsHoleSummary, IntegrationKindByCategory<"dnsHole">, Record @@ -15,6 +13,4 @@ export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getSummaryAsync(); }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "dnsHoleSummary", }); diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 2d228a598..12a78c05a 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -1,5 +1,4 @@ import type { Readable } from "node:stream"; -import dayjs from "dayjs"; import type { Container, ContainerInfo, ContainerStats } from "dockerode"; import type Dockerode from "dockerode"; @@ -13,7 +12,7 @@ import type { ContainerState, Port } from "@homarr/docker"; import { dockerLabels, DockerSingleton } from "@homarr/docker"; import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode"; -import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; +import { createWidgetRequestHandler } from "./lib/widget-request-handler"; const logger = createLogger({ module: "dockerRequestHandler" }); @@ -171,16 +170,13 @@ const mockContainers: { }, ]; -export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({ - queryKey: "dockerContainersResult", - widgetKind: "dockerContainers", +export const dockerContainersRequestHandler = createWidgetRequestHandler({ async requestAsync() { if (isDemoMode) { return mockContainers; } return await getContainersWithStatsAsync(); }, - cacheDuration: dayjs.duration(20, "seconds"), }); const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image); diff --git a/packages/request-handler/src/downloads.ts b/packages/request-handler/src/downloads.ts index 06d4e9c64..12106b07a 100644 --- a/packages/request-handler/src/downloads.ts +++ b/packages/request-handler/src/downloads.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const downloadClientRequestHandler = createCachedIntegrationRequestHandler< +export const downloadClientRequestHandler = createIntegrationRequestHandler< DownloadClientJobsAndStatus, IntegrationKindByCategory<"downloadClient">, { limit: number } @@ -15,6 +13,4 @@ export const downloadClientRequestHandler = createCachedIntegrationRequestHandle const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getClientJobsAndStatusAsync(input); }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "downloadClientJobStatus", }); diff --git a/packages/request-handler/src/duckduckgo-bangs.ts b/packages/request-handler/src/duckduckgo-bangs.ts index a51bf5327..628e125a1 100644 --- a/packages/request-handler/src/duckduckgo-bangs.ts +++ b/packages/request-handler/src/duckduckgo-bangs.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; import { z } from "zod"; import { env } from "@homarr/common/env"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; -import { createChannelWithLatestAndEvents } from "@homarr/redis"; -import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler"; +import { createRequestHandler } from "@homarr/request-handler/lib/request-handler"; export type DuckDuckGoBang = z.infer; @@ -38,9 +36,7 @@ const normalizeBangToken = (token: string) => token.toLowerCase().trim(); const compareBangTokenAsc = (left: DuckDuckGoBang, right: DuckDuckGoBang) => left.t.localeCompare(right.t); -export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({ - queryKey: "duckduckgo-bangs", - cacheDuration: dayjs.duration(1, "day"), +export const duckDuckGoBangsRequestHandler = createRequestHandler({ async requestAsync(_) { if (env.NO_EXTERNAL_CONNECTION) { return []; @@ -73,7 +69,4 @@ export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({ return normalized; }, - createRedisChannel() { - return createChannelWithLatestAndEvents("homarr:duckduckgo-bangs"); - }, }); diff --git a/packages/request-handler/src/firewall.ts b/packages/request-handler/src/firewall.ts index 3754f1099..ad4f25b70 100644 --- a/packages/request-handler/src/firewall.ts +++ b/packages/request-handler/src/firewall.ts @@ -1,5 +1,3 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { @@ -9,9 +7,9 @@ import type { FirewallVersionSummary, } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler< +export const firewallCpuRequestHandler = createIntegrationRequestHandler< FirewallCpuSummary, IntegrationKindByCategory<"firewall">, Record @@ -20,11 +18,9 @@ export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return integrationInstance.getFirewallCpuAsync(); }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "firewallCpuSummary", }); -export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandler< +export const firewallMemoryRequestHandler = createIntegrationRequestHandler< FirewallMemorySummary, IntegrationKindByCategory<"firewall">, Record @@ -33,11 +29,9 @@ export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandle const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getFirewallMemoryAsync(); }, - cacheDuration: dayjs.duration(15, "seconds"), - queryKey: "firewallMemorySummary", }); -export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHandler< +export const firewallInterfacesRequestHandler = createIntegrationRequestHandler< FirewallInterfacesSummary[], IntegrationKindByCategory<"firewall">, Record @@ -46,11 +40,9 @@ export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHa const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getFirewallInterfacesAsync(); }, - cacheDuration: dayjs.duration(30, "seconds"), - queryKey: "firewallInterfacesSummary", }); -export const firewallVersionRequestHandler = createCachedIntegrationRequestHandler< +export const firewallVersionRequestHandler = createIntegrationRequestHandler< FirewallVersionSummary, IntegrationKindByCategory<"firewall">, Record @@ -59,6 +51,4 @@ export const firewallVersionRequestHandler = createCachedIntegrationRequestHandl const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getFirewallVersionAsync(); }, - cacheDuration: dayjs.duration(1, "hour"), - queryKey: "firewallVersionSummary", }); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 640eb6b97..604fff9da 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { ProxmoxClusterInfo, SystemHealthMonitoring } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< +export const systemInfoRequestHandler = createIntegrationRequestHandler< SystemHealthMonitoring, Exclude, "proxmox" | "coolify" | "beszel">, Record @@ -15,11 +13,9 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getSystemInfoAsync(); }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "systemInfo", }); -export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler< +export const clusterInfoRequestHandler = createIntegrationRequestHandler< ProxmoxClusterInfo, "proxmox" | "mock", Record @@ -28,6 +24,4 @@ export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getClusterInfoAsync(); }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "clusterInfo", }); diff --git a/packages/request-handler/src/immich.ts b/packages/request-handler/src/immich.ts index e95cb32b8..56fc89bd0 100644 --- a/packages/request-handler/src/immich.ts +++ b/packages/request-handler/src/immich.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { ImmichAlbum, ImmichServerStats } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const immichStatsRequestHandler = createCachedIntegrationRequestHandler< +export const immichStatsRequestHandler = createIntegrationRequestHandler< ImmichServerStats, IntegrationKindByCategory<"photoService">, Record @@ -15,11 +13,9 @@ export const immichStatsRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getServerStatsAsync(); }, - cacheDuration: dayjs.duration(15, "minute"), - queryKey: "immich-server-stats", }); -export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler< +export const immichAlbumsRequestHandler = createIntegrationRequestHandler< { id: string; albumName: string; @@ -32,11 +28,9 @@ export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getAlbumsAsync(); }, - cacheDuration: dayjs.duration(15, "minute"), - queryKey: "immich-albums", }); -export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler< +export const immichAlbumRequestHandler = createIntegrationRequestHandler< ImmichAlbum, IntegrationKindByCategory<"photoService">, { albumId: string } @@ -45,6 +39,4 @@ export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getAlbumAsync(input.albumId); }, - cacheDuration: dayjs.duration(15, "minute"), - queryKey: "immich-album", }); diff --git a/packages/request-handler/src/indexer-manager.ts b/packages/request-handler/src/indexer-manager.ts index a957b32ad..f21a61f8f 100644 --- a/packages/request-handler/src/indexer-manager.ts +++ b/packages/request-handler/src/indexer-manager.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { Indexer } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const indexerManagerRequestHandler = createCachedIntegrationRequestHandler< +export const indexerManagerRequestHandler = createIntegrationRequestHandler< Indexer[], IntegrationKindByCategory<"indexerManager">, Record @@ -15,6 +13,4 @@ export const indexerManagerRequestHandler = createCachedIntegrationRequestHandle const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getIndexersAsync(); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "indexerManager", }); diff --git a/packages/request-handler/src/lib/cached-integration-request-handler.ts b/packages/request-handler/src/lib/cached-integration-request-handler.ts deleted file mode 100644 index aee69e39c..000000000 --- a/packages/request-handler/src/lib/cached-integration-request-handler.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Duration } from "dayjs/plugin/duration"; - -import type { Modify } from "@homarr/common/types"; -import type { Integration, IntegrationSecret } from "@homarr/db/schema"; -import type { IntegrationKind } from "@homarr/definitions"; -import { createIntegrationOptionsChannel } from "@homarr/redis"; - -import { createCachedRequestHandler } from "./cached-request-handler"; - -type IntegrationOfKind = Omit & { - kind: TKind; - decryptedSecrets: Modify, { value: string }>[]; - externalUrl: string | null; -}; - -interface Options> { - queryKey: string; - requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise; - cacheDuration: Duration; - cacheDurationForInput?: (input: TInput) => Duration | undefined; - fallbackToStaleOnError?: boolean; - retry?: { attempts?: number; delayMs?: number }; - isValid?: (data: TData) => boolean; -} - -export const createCachedIntegrationRequestHandler = < - TData, - TKind extends IntegrationKind, - TInput extends Record, ->( - options: Options, -) => { - return { - handler: (integration: IntegrationOfKind, itemOptions: TInput) => - createCachedRequestHandler({ - queryKey: options.queryKey, - requestAsync: async (input: { options: TInput; integration: IntegrationOfKind }) => { - return await options.requestAsync(input.integration, input.options); - }, - cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration, - fallbackToStaleOnError: options.fallbackToStaleOnError, - retry: options.retry, - isValid: options.isValid, - createRedisChannel(input, handlerOptions) { - return createIntegrationOptionsChannel(input.integration.id, handlerOptions.queryKey, input.options); - }, - }).handler({ options: itemOptions, integration }), - }; -}; diff --git a/packages/request-handler/src/lib/cached-request-handler.ts b/packages/request-handler/src/lib/cached-request-handler.ts deleted file mode 100644 index c2342eed3..000000000 --- a/packages/request-handler/src/lib/cached-request-handler.ts +++ /dev/null @@ -1,160 +0,0 @@ -import dayjs from "dayjs"; -import type { Duration } from "dayjs/plugin/duration"; - -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import type { createChannelWithLatestAndEvents } from "@homarr/redis"; - -const logger = createLogger({ module: "cachedRequestHandler" }); - -type FetchResult = { data: TData; timestamp: Date }; -type InFlightEntry = Promise>; - -const inFlightFetches = new Map(); - -const isAbortedError = (error: unknown): boolean => { - if (!(error instanceof Error)) return false; - return error.name === "AbortError" || error.name === "TimeoutError"; -}; - -interface Options> { - // Unique key for this request handler - queryKey: string; - requestAsync: (input: TInput) => Promise; - createRedisChannel: ( - input: TInput, - options: Options, - ) => ReturnType>; - cacheDuration: Duration; - fallbackToStaleOnError?: boolean; - // Retry the upstream call on failure. attempts=1 means no retry (default). - // AbortError / TimeoutError are never retried. - retry?: { attempts?: number; delayMs?: number }; - // Predicate to decide whether a fetched response is worth caching. - // Return false to skip the cache write (next call will refetch). - isValid?: (data: TData) => boolean; -} - -const defaultRetry = { attempts: 1, delayMs: 0 }; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export const createCachedRequestHandler = >( - options: Options, -) => { - return { - handler: (input: TInput) => { - const channel = options.createRedisChannel(input, options); - const retryConfig = { ...defaultRetry, ...options.retry }; - - return { - async getCachedOrUpdatedDataAsync({ forceUpdate = false }) { - const channelData = await channel.getAsync(); - - const requestNewDataAsync = (): Promise> => { - const inflight = inFlightFetches.get(channel.name); - if (inflight) { - logger.debug("Cached request handler joining in-flight fetch", { - channel: channel.name, - queryKey: options.queryKey, - }); - return inflight as Promise>; - } - - const promise = (async (): Promise> => { - let lastError: unknown; - for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) { - try { - const data = await options.requestAsync(input); - if (options.isValid && !options.isValid(data)) { - logger.warn("Cached request handler received invalid data, not caching", { - channel: channel.name, - queryKey: options.queryKey, - attempt, - }); - return { data, timestamp: new Date() }; - } - await channel.publishAndUpdateLastStateAsync(data); - return { data, timestamp: new Date() }; - } catch (error) { - lastError = error; - if (isAbortedError(error) || attempt === retryConfig.attempts) { - break; - } - logger.warn("Cached request handler fetch failed, retrying", { - channel: channel.name, - queryKey: options.queryKey, - attempt, - nextDelayMs: retryConfig.delayMs, - error: error instanceof Error ? `${error.name}: ${error.message}` : String(error), - }); - await sleep(retryConfig.delayMs); - } - } - - if (options.fallbackToStaleOnError && channelData) { - logger.warn( - new ErrorWithMetadata( - "Cached request handler using stale cache after fetch failure", - { - channel: channel.name, - queryKey: options.queryKey, - attempts: retryConfig.attempts, - }, - { cause: lastError }, - ), - ); - return channelData; - } - - throw lastError; - })().finally(() => { - inFlightFetches.delete(channel.name); - }); - - inFlightFetches.set(channel.name, promise as InFlightEntry); - return promise; - }; - - if (forceUpdate) { - logger.debug("Cached request handler forced update", { - channel: channel.name, - queryKey: options.queryKey, - }); - return await requestNewDataAsync(); - } - - const shouldRequestNewData = - !channelData || - dayjs().diff(channelData.timestamp, "milliseconds") > options.cacheDuration.asMilliseconds(); - - if (shouldRequestNewData) { - logger.debug("Cached request handler cache miss", { - channel: channel.name, - queryKey: options.queryKey, - reason: !channelData ? "no data" : "cache expired", - }); - return await requestNewDataAsync(); - } - - logger.debug("Cached request handler cache hit", { - channel: channel.name, - queryKey: options.queryKey, - expiresAt: dayjs(channelData.timestamp).add(options.cacheDuration).toISOString(), - }); - - return channelData; - }, - async invalidateAsync() { - logger.debug("Cached request handler invalidating cache", { - channel: channel.name, - queryKey: options.queryKey, - }); - await this.getCachedOrUpdatedDataAsync({ forceUpdate: true }); - }, - subscribe(callback: (data: TData) => void) { - return channel.subscribe(callback); - }, - }; - }, - }; -}; diff --git a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts deleted file mode 100644 index 63e7682c2..000000000 --- a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts +++ /dev/null @@ -1,115 +0,0 @@ -import SuperJSON from "superjson"; - -import { hashObjectBase64, Stopwatch } from "@homarr/common"; -import { decryptSecret } from "@homarr/common/server"; -import type { MaybeArray } from "@homarr/common/types"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { db } from "@homarr/db"; -import { getItemsWithIntegrationsAsync, getServerSettingsAsync } from "@homarr/db/queries"; -import type { WidgetKind } from "@homarr/definitions"; - -// 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"; - -const logger = createLogger({ module: "cachedRequestIntegrationJobHandler" }); - -export const createRequestIntegrationJobHandler = < - TWidgetKind extends WidgetKind, - TIntegrationKind extends inferSupportedIntegrationsStrict, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - THandler extends ReturnType>["handler"], ->( - handler: THandler, - { - widgetKinds, - getInput, - }: { - widgetKinds: TWidgetKind[]; - getInput: { - [key in TWidgetKind]: (options: WidgetComponentProps["options"]) => MaybeArray[1]>; - }; - }, -) => { - return async () => { - const serverSettings = await getServerSettingsAsync(db); - const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { - kinds: widgetKinds, - }); - - logger.debug("Found items for integration", { - widgetKinds: widgetKinds.join(","), - count: itemsForIntegration.length, - }); - - const distinctIntegrations: { - integrationId: string; - inputHash: string; - integration: (typeof itemsForIntegration)[number]["integrations"][number]["integration"]; - input: Parameters[1]; - }[] = []; - - for (const itemForIntegration of itemsForIntegration) { - const oneOrMultipleInputs = getInput[itemForIntegration.kind]( - reduceWidgetOptionsWithDefaultValues( - itemForIntegration.kind, - { - enableStatusByDefault: serverSettings.board.enableStatusByDefault, - forceDisableStatus: serverSettings.board.forceDisableStatus, - }, - 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, - externalUrl: integration.app?.href ?? null, - }, - input, - ); - const stopWatch = new Stopwatch(); - await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); - logger.debug("Ran integration job", { - integration: integrationId, - inputHash, - elapsed: stopWatch.getElapsedInHumanWords(), - }); - } catch (error) { - logger.error( - new ErrorWithMetadata("Failed to run integration job", { integrationId, inputHash }, { cause: error }), - ); - } - } - }; -}; diff --git a/packages/request-handler/src/lib/cached-widget-request-handler.ts b/packages/request-handler/src/lib/cached-widget-request-handler.ts deleted file mode 100644 index 4016bc5b4..000000000 --- a/packages/request-handler/src/lib/cached-widget-request-handler.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Duration } from "dayjs/plugin/duration"; - -import type { WidgetKind } from "@homarr/definitions"; -import { createWidgetOptionsChannel } from "@homarr/redis"; - -import { createCachedRequestHandler } from "./cached-request-handler"; - -interface Options> { - // Unique key for this request handler - queryKey: string; - requestAsync: (input: TInput) => Promise; - cacheDuration: Duration; - widgetKind: TKind; -} - -export const createCachedWidgetRequestHandler = < - TData, - TKind extends WidgetKind, - TInput extends Record, ->( - requestHandlerOptions: Options, -) => { - return { - handler: (widgetOptions: TInput) => - createCachedRequestHandler({ - queryKey: requestHandlerOptions.queryKey, - requestAsync: async (input: TInput) => { - return await requestHandlerOptions.requestAsync(input); - }, - cacheDuration: requestHandlerOptions.cacheDuration, - createRedisChannel(input, options) { - return createWidgetOptionsChannel(requestHandlerOptions.widgetKind, options.queryKey, input); - }, - }).handler(widgetOptions), - }; -}; diff --git a/packages/request-handler/src/lib/integration-request-handler.ts b/packages/request-handler/src/lib/integration-request-handler.ts new file mode 100644 index 000000000..d2fa0f1a4 --- /dev/null +++ b/packages/request-handler/src/lib/integration-request-handler.ts @@ -0,0 +1,30 @@ +import type { Modify } from "@homarr/common/types"; +import type { Integration, IntegrationSecret } from "@homarr/db/schema"; +import type { IntegrationKind } from "@homarr/definitions"; + +import { createRequestHandler } from "./request-handler"; + +type IntegrationOfKind = Omit & { + kind: TKind; + decryptedSecrets: Modify, { value: string }>[]; + externalUrl: string | null; +}; + +interface Options> { + requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise; +} + +export const createIntegrationRequestHandler = < + TData, + TKind extends IntegrationKind, + TInput extends Record, +>( + options: Options, +) => ({ + handler: (integration: IntegrationOfKind, itemOptions: TInput) => { + const inner = createRequestHandler({ + requestAsync: async (input) => options.requestAsync(integration, input.options), + }); + return inner.handler({ integrationId: integration.id, options: itemOptions }); + }, +}); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts new file mode 100644 index 000000000..7018dd4ee --- /dev/null +++ b/packages/request-handler/src/lib/request-handler.ts @@ -0,0 +1,60 @@ +interface Options> { + requestAsync: (input: TInput) => Promise; + cacheTtlMs?: number; +} + +type CacheEntry = { data: TData; timestamp: Date; expiresAt: number }; + +const MAX_CACHE_SIZE = 1000; +const cache = new Map>(); +const inflight = new Map>>(); + +const DEFAULT_TTL_MS = 10_000; + +const evictExpired = () => { + const now = Date.now(); + for (const [key, entry] of cache) { + if (now >= entry.expiresAt) cache.delete(key); + } +}; + +export const createRequestHandler = >( + options: Options, +) => ({ + handler: (input: TInput) => ({ + async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { + const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; + const key = JSON.stringify(input); + + const cached = cache.get(key) as CacheEntry | undefined; + if (cached && Date.now() < cached.expiresAt) { + return { data: cached.data, timestamp: cached.timestamp }; + } + if (cached) cache.delete(key); + + const existing = inflight.get(key); + if (existing) return existing as Promise>; + + const promise = options + .requestAsync(input) + .then((data) => { + if (cache.size >= MAX_CACHE_SIZE) evictExpired(); + if (cache.size >= MAX_CACHE_SIZE) { + const oldest = cache.keys().next().value; + if (oldest) cache.delete(oldest); + } + const entry: CacheEntry = { data, timestamp: new Date(), expiresAt: Date.now() + ttl }; + cache.set(key, entry as CacheEntry); + inflight.delete(key); + return entry; + }) + .catch((err) => { + inflight.delete(key); + throw err; + }); + + inflight.set(key, promise as Promise>); + return promise; + }, + }), +}); diff --git a/packages/request-handler/src/lib/widget-request-handler.ts b/packages/request-handler/src/lib/widget-request-handler.ts new file mode 100644 index 000000000..8649791af --- /dev/null +++ b/packages/request-handler/src/lib/widget-request-handler.ts @@ -0,0 +1,14 @@ +import { createRequestHandler } from "./request-handler"; + +interface Options> { + requestAsync: (input: TInput) => Promise; +} + +export const createWidgetRequestHandler = >( + options: Options, +) => ({ + handler: (widgetOptions: TInput) => + createRequestHandler({ + requestAsync: options.requestAsync, + }).handler(widgetOptions), +}); diff --git a/packages/request-handler/src/media-release.ts b/packages/request-handler/src/media-release.ts index c0ffe48f3..02209f68f 100644 --- a/packages/request-handler/src/media-release.ts +++ b/packages/request-handler/src/media-release.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { MediaRelease } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const mediaReleaseRequestHandler = createCachedIntegrationRequestHandler< +export const mediaReleaseRequestHandler = createIntegrationRequestHandler< MediaRelease[], IntegrationKindByCategory<"mediaRelease">, Record @@ -15,6 +13,4 @@ export const mediaReleaseRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getMediaReleasesAsync(); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "mediaReleases", }); diff --git a/packages/request-handler/src/media-request-list.ts b/packages/request-handler/src/media-request-list.ts index fd4e02234..62cf29a18 100644 --- a/packages/request-handler/src/media-request-list.ts +++ b/packages/request-handler/src/media-request-list.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { MediaRequest } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const mediaRequestListRequestHandler = createCachedIntegrationRequestHandler< +export const mediaRequestListRequestHandler = createIntegrationRequestHandler< MediaRequest[], IntegrationKindByCategory<"mediaRequest">, Record @@ -15,6 +13,4 @@ export const mediaRequestListRequestHandler = createCachedIntegrationRequestHand const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getRequestsAsync(); }, - cacheDuration: dayjs.duration(1, "minute"), - queryKey: "mediaRequestList", }); diff --git a/packages/request-handler/src/media-request-stats.ts b/packages/request-handler/src/media-request-stats.ts index a6568aecd..61ff810b8 100644 --- a/packages/request-handler/src/media-request-stats.ts +++ b/packages/request-handler/src/media-request-stats.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { MediaRequestStats } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHandler< +export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler< MediaRequestStats, IntegrationKindByCategory<"mediaRequest">, Record @@ -18,6 +16,4 @@ export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHan users: await integrationInstance.getUsersAsync(), }; }, - cacheDuration: dayjs.duration(1, "minute"), - queryKey: "mediaRequestStats", }); diff --git a/packages/request-handler/src/media-server.ts b/packages/request-handler/src/media-server.ts index b13b90eb0..55a7850e0 100644 --- a/packages/request-handler/src/media-server.ts +++ b/packages/request-handler/src/media-server.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const mediaServerRequestHandler = createCachedIntegrationRequestHandler< +export const mediaServerRequestHandler = createIntegrationRequestHandler< StreamSession[], IntegrationKindByCategory<"mediaService">, { @@ -17,6 +15,4 @@ export const mediaServerRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying }); }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "mediaServerSessions", }); diff --git a/packages/request-handler/src/media-transcoding.ts b/packages/request-handler/src/media-transcoding.ts index 698d7347b..ba6455ccd 100644 --- a/packages/request-handler/src/media-transcoding.ts +++ b/packages/request-handler/src/media-transcoding.ts @@ -1,18 +1,14 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHandler< +export const mediaTranscodingRequestHandler = createIntegrationRequestHandler< MediaTranscoding, IntegrationKindByCategory<"mediaTranscoding">, { pageOffset: number; pageSize: number } >({ - queryKey: "mediaTranscoding", - cacheDuration: dayjs.duration(5, "minutes"), async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return { diff --git a/packages/request-handler/src/minecraft-server-status.ts b/packages/request-handler/src/minecraft-server-status.ts index 98d850171..664a0a7f3 100644 --- a/packages/request-handler/src/minecraft-server-status.ts +++ b/packages/request-handler/src/minecraft-server-status.ts @@ -1,14 +1,11 @@ -import dayjs from "dayjs"; import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; -import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; +import { createWidgetRequestHandler } from "./lib/widget-request-handler"; -export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHandler({ - queryKey: "minecraftServerStatusApiResult", - widgetKind: "minecraftServerStatus", +export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({ async requestAsync(input: { domain: string; isBedrockServer: boolean }) { const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`; @@ -17,7 +14,6 @@ export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHand ); return responseSchema.parse(await response.json()); }, - cacheDuration: dayjs.duration(5, "minutes"), }); const responseSchema = z diff --git a/packages/request-handler/src/navidrome.ts b/packages/request-handler/src/navidrome.ts index b59f852a8..0ad39ed9a 100644 --- a/packages/request-handler/src/navidrome.ts +++ b/packages/request-handler/src/navidrome.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import { createIntegrationAsync } from "@homarr/integrations"; import type { NavidromeDashboardData } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const navidromeRequestHandler = createCachedIntegrationRequestHandler< +export const navidromeRequestHandler = createIntegrationRequestHandler< NavidromeDashboardData, "navidrome", Record @@ -14,6 +12,4 @@ export const navidromeRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); }, - cacheDuration: dayjs.duration(10, "minutes"), - queryKey: "navidromeDashboard", }); diff --git a/packages/request-handler/src/network-controller.ts b/packages/request-handler/src/network-controller.ts index f5975acf2..123261025 100644 --- a/packages/request-handler/src/network-controller.ts +++ b/packages/request-handler/src/network-controller.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { NetworkControllerSummary } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const networkControllerRequestHandler = createCachedIntegrationRequestHandler< +export const networkControllerRequestHandler = createIntegrationRequestHandler< NetworkControllerSummary, IntegrationKindByCategory<"networkController">, Record @@ -15,6 +13,4 @@ export const networkControllerRequestHandler = createCachedIntegrationRequestHan const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getNetworkSummaryAsync(); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "networkControllerSummary", }); diff --git a/packages/request-handler/src/notifications.ts b/packages/request-handler/src/notifications.ts index 41098e660..479ff81fa 100644 --- a/packages/request-handler/src/notifications.ts +++ b/packages/request-handler/src/notifications.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { Notification } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const notificationsRequestHandler = createCachedIntegrationRequestHandler< +export const notificationsRequestHandler = createIntegrationRequestHandler< Notification[], IntegrationKindByCategory<"notifications">, Record @@ -15,6 +13,4 @@ export const notificationsRequestHandler = createCachedIntegrationRequestHandler const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getNotificationsAsync(); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "notificationsJobStatus", }); diff --git a/packages/request-handler/src/paperless-ngx.ts b/packages/request-handler/src/paperless-ngx.ts index a3793658f..e6b088006 100644 --- a/packages/request-handler/src/paperless-ngx.ts +++ b/packages/request-handler/src/paperless-ngx.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import { createIntegrationAsync } from "@homarr/integrations"; import type { PaperlessNgxStats } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHandler< +export const paperlessNgxStatsRequestHandler = createIntegrationRequestHandler< PaperlessNgxStats, "paperlessNgx", Record @@ -14,6 +12,4 @@ export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHan const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getStatsAsync(); }, - cacheDuration: dayjs.duration(15, "minute"), - queryKey: "paperless-ngx-stats", }); diff --git a/packages/request-handler/src/release-providers.ts b/packages/request-handler/src/release-providers.ts index 925507336..f4a647668 100644 --- a/packages/request-handler/src/release-providers.ts +++ b/packages/request-handler/src/release-providers.ts @@ -127,7 +127,7 @@ const parseOwnerPackageName = (identifier: string) => { const removeTrailingSlash = (url: string) => url.replace(/\/$/, ""); -const buildUrl = (baseUrl: string, path: `/${string}`) => new URL(`${baseUrl}${path}`); +const buildUrl = (baseUrl: string, path: `/${string}`) => new URL(`${baseUrl}${path}`).toString(); const getGithubApi = (baseUrl: string, userAgent: string, token?: string) => new Octokit({ diff --git a/packages/request-handler/src/releases.ts b/packages/request-handler/src/releases.ts index 683c292fb..e089bc661 100644 --- a/packages/request-handler/src/releases.ts +++ b/packages/request-handler/src/releases.ts @@ -1,19 +1,10 @@ -import dayjs from "dayjs"; - -import { createWidgetOptionsChannel } from "@homarr/redis"; - -import { createCachedRequestHandler } from "./lib/cached-request-handler"; import type { ReleaseResponse, ReleasesRepositoryRequest } from "./release-providers"; import { getLatestMatchingReleaseAsync } from "./release-providers"; +import { createRequestHandler } from "./lib/request-handler"; -export const releasesRequestHandler = { - handler: (itemOptions: ReleasesRepositoryRequest) => - createCachedRequestHandler({ - requestAsync: async (input) => await getLatestMatchingReleaseAsync(input), - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "repositoriesReleases", - createRedisChannel(input, handlerOptions) { - return createWidgetOptionsChannel("releases", handlerOptions.queryKey, input); - }, - }).handler(itemOptions), -}; +export const releasesRequestHandler = createRequestHandler({ + async requestAsync(input) { + return await getLatestMatchingReleaseAsync(input); + }, + cacheTtlMs: 5 * 60 * 1000, +}); diff --git a/packages/request-handler/src/rss-feeds.ts b/packages/request-handler/src/rss-feeds.ts index 9eaf026ea..0597a325f 100644 --- a/packages/request-handler/src/rss-feeds.ts +++ b/packages/request-handler/src/rss-feeds.ts @@ -1,18 +1,15 @@ import type { FeedData, FeedEntry } from "@extractus/feed-extractor"; import { extract } from "@extractus/feed-extractor"; -import dayjs from "dayjs"; import { z } from "zod/v4"; import type { Modify } from "@homarr/common/types"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; +import { createWidgetRequestHandler } from "./lib/widget-request-handler"; const logger = createLogger({ module: "rssFeedsRequestHandler" }); -export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({ - queryKey: "rssFeedList", - widgetKind: "rssFeed", +export const rssFeedsRequestHandler = createWidgetRequestHandler({ async requestAsync(input: { url: string; count: number }) { const result = (await extract(input.url, { getExtraEntryFields: (feedEntry) => { @@ -31,7 +28,6 @@ export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({ entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [], }; }, - cacheDuration: dayjs.duration(5, "minutes"), }); const attemptGetImageFromEntry = (feedUrl: string, entry: object) => { diff --git a/packages/request-handler/src/smart-home-entity-state.ts b/packages/request-handler/src/smart-home-entity-state.ts index 5fe02b2ac..3c8851522 100644 --- a/packages/request-handler/src/smart-home-entity-state.ts +++ b/packages/request-handler/src/smart-home-entity-state.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const smartHomeEntityStateRequestHandler = createCachedIntegrationRequestHandler< +export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler< string, IntegrationKindByCategory<"smartHomeServer">, { entityId: string } @@ -20,6 +18,4 @@ export const smartHomeEntityStateRequestHandler = createCachedIntegrationRequest return result.data.state; }, - cacheDuration: dayjs.duration(1, "minute"), - queryKey: "smartHome-entityState", }); diff --git a/packages/request-handler/src/speedtest-tracker.ts b/packages/request-handler/src/speedtest-tracker.ts index 799d37f16..3f03f7187 100644 --- a/packages/request-handler/src/speedtest-tracker.ts +++ b/packages/request-handler/src/speedtest-tracker.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import { createIntegrationAsync } from "@homarr/integrations"; import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHandler< +export const speedtestTrackerRequestHandler = createIntegrationRequestHandler< SpeedtestTrackerDashboardData, "speedtestTracker", Record @@ -14,6 +12,4 @@ export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHand const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); }, - cacheDuration: dayjs.duration(10, "minutes"), - queryKey: "speedtestTrackerDashboard", }); diff --git a/packages/request-handler/src/stock-price.ts b/packages/request-handler/src/stock-price.ts index b0ca15e47..177b223aa 100644 --- a/packages/request-handler/src/stock-price.ts +++ b/packages/request-handler/src/stock-price.ts @@ -1,14 +1,11 @@ -import dayjs from "dayjs"; import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; -import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; +import { createWidgetRequestHandler } from "./lib/widget-request-handler"; -export const fetchStockPriceHandler = createCachedWidgetRequestHandler({ - queryKey: "fetchStockPriceResult", - widgetKind: "stockPrice", +export const fetchStockPriceHandler = createWidgetRequestHandler({ async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) { const response = await withTimeoutAsync(async (signal) => { return await fetchWithTrustedCertificatesAsync( @@ -42,7 +39,6 @@ export const fetchStockPriceHandler = createCachedWidgetRequestHandler({ shortName: firstResult.meta.shortName, }; }, - cacheDuration: dayjs.duration(5, "minutes"), }); const dataSchema = z diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts index 166f1e8ee..2be7355d8 100644 --- a/packages/request-handler/src/timetable.ts +++ b/packages/request-handler/src/timetable.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; import { z } from "zod/v4"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { createWidgetOptionsChannel } from "@homarr/redis"; -import { createCachedRequestHandler } from "./lib/cached-request-handler"; +import { createRequestHandler } from "./lib/request-handler"; export interface Station { id: string; @@ -34,40 +32,32 @@ export interface Timetable { const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"]; -export const timetableSearchStationsRequestHandler = { - handler: (itemOptions: { baseUrl: string; query: string }) => - createCachedRequestHandler({ - async requestAsync(input) { - return await searchStationsAsync(input.baseUrl, input.query); - }, - queryKey: "timetableSearchStations", - cacheDuration: dayjs.duration(1, "day"), - createRedisChannel(input, handlerOptions) { - return createWidgetOptionsChannel("timetable", handlerOptions.queryKey, input); - }, - }).handler(itemOptions), -}; +export const timetableSearchStationsRequestHandler = createRequestHandler< + Station[], + { baseUrl: string; query: string } +>({ + async requestAsync(input) { + return await searchStationsAsync(input.baseUrl, input.query); + }, + cacheTtlMs: 24 * 60 * 60 * 1000, +}); -export const timetableGetTimetableRequestHandler = { - handler: (itemOptions: { baseUrl: string; stationId: string; limit: number }) => - createCachedRequestHandler({ - async requestAsync(input) { - return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit }); - }, - queryKey: "timetableGetTimetable", - cacheDuration: dayjs.duration(1, "minute"), - createRedisChannel(input, handlerOptions) { - return createWidgetOptionsChannel("timetable", handlerOptions.queryKey, input); - }, - }).handler(itemOptions), -}; +export const timetableGetTimetableRequestHandler = createRequestHandler< + Timetable, + { baseUrl: string; stationId: string; limit: number } +>({ + async requestAsync(input) { + return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit }); + }, + cacheTtlMs: 60_000, +}); const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record) => { const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`); for (const [key, value] of Object.entries(queryParams)) { url.searchParams.set(key, value.toString()); } - return url; + return url.toString(); }; const searchStationsAsync = async (baseUrl: string, query: string): Promise => { diff --git a/packages/request-handler/src/tracearr.ts b/packages/request-handler/src/tracearr.ts index 8f593ac03..641bf2922 100644 --- a/packages/request-handler/src/tracearr.ts +++ b/packages/request-handler/src/tracearr.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import { createIntegrationAsync } from "@homarr/integrations"; import type { TracearrDashboardData } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const tracearrRequestHandler = createCachedIntegrationRequestHandler< +export const tracearrRequestHandler = createIntegrationRequestHandler< TracearrDashboardData, "tracearr", Record @@ -14,6 +12,4 @@ export const tracearrRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "tracearrDashboard", }); diff --git a/packages/request-handler/src/umami.ts b/packages/request-handler/src/umami.ts index e943c568a..02ef71140 100644 --- a/packages/request-handler/src/umami.ts +++ b/packages/request-handler/src/umami.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import { createIntegrationAsync } from "@homarr/integrations"; import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const umamiRequestHandler = createCachedIntegrationRequestHandler< +export const umamiRequestHandler = createIntegrationRequestHandler< UmamiVisitorStats, "umami", { websiteId: string; timeFrame: string; eventName?: string } @@ -14,24 +12,16 @@ export const umamiRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "umamiVisitorStats", }); -export const umamiEventNamesRequestHandler = createCachedIntegrationRequestHandler< - string[], - "umami", - { websiteId: string } ->({ +export const umamiEventNamesRequestHandler = createIntegrationRequestHandler({ async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getEventNamesAsync(input.websiteId); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "umamiEventNames", }); -export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler< +export const umamiTopPagesRequestHandler = createIntegrationRequestHandler< UmamiMetricItem[], "umami", { websiteId: string; timeFrame: string; limit: number } @@ -40,11 +30,9 @@ export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "umamiTopPages", }); -export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHandler< +export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler< UmamiMetricItem[], "umami", { websiteId: string; timeFrame: string; limit: number } @@ -53,11 +41,9 @@ export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHan const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "umamiTopReferrers", }); -export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandler< +export const umamiMultiEventRequestHandler = createIntegrationRequestHandler< UmamiEventSeries[], "umami", { websiteId: string; timeFrame: string; eventNames: string[] } @@ -66,11 +52,9 @@ export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandl const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames); }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "umamiMultiEvent", }); -export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestHandler< +export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler< number, "umami", { websiteId: string } @@ -79,6 +63,4 @@ export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestH const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getActiveVisitorsAsync(input.websiteId); }, - cacheDuration: dayjs.duration(30, "seconds"), - queryKey: "umamiActiveVisitors", }); diff --git a/packages/request-handler/src/update-checker.ts b/packages/request-handler/src/update-checker.ts index 8709f20c5..1cf4dbd6f 100644 --- a/packages/request-handler/src/update-checker.ts +++ b/packages/request-handler/src/update-checker.ts @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import { Octokit } from "octokit"; import { compareSemVer, isValidSemVer, parseSemVer } from "semver-parser"; @@ -6,27 +5,18 @@ import { env } from "@homarr/common/env"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { createChannelWithLatestAndEvents } from "@homarr/redis"; -import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler"; +import { createRequestHandler } from "./lib/request-handler"; import packageJson from "../../../package.json"; const logger = createLogger({ module: "updateCheckerRequestHandler" }); -export const updateCheckerRequestHandler = createCachedRequestHandler({ - queryKey: "homarr-update-checker", - cacheDuration: dayjs.duration(1, "day"), - fallbackToStaleOnError: true, +export const updateCheckerRequestHandler = createRequestHandler({ async requestAsync(_) { - return { - availableUpdates: await getAvailableUpdatesAsync(packageJson.version), - }; - }, - createRedisChannel() { - return createChannelWithLatestAndEvents<{ - availableUpdates: Update[]; - }>("homarr:update"); + const availableUpdates = await getAvailableUpdatesAsync(packageJson.version); + return { availableUpdates }; }, + cacheTtlMs: 30 * 60 * 1000, }); interface Update { diff --git a/packages/request-handler/src/ups.ts b/packages/request-handler/src/ups.ts index acc97eb6d..4ca17b5ee 100644 --- a/packages/request-handler/src/ups.ts +++ b/packages/request-handler/src/ups.ts @@ -1,12 +1,10 @@ -import dayjs from "dayjs"; - import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { UpsSummary } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler< +export const upsSummariesRequestHandler = createIntegrationRequestHandler< UpsSummary[], IntegrationKindByCategory<"ups">, Record @@ -15,6 +13,4 @@ export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getUpsSummariesAsync(); }, - cacheDuration: dayjs.duration(1, "minute"), - queryKey: "upsSummaries", }); diff --git a/packages/request-handler/src/uptime-kuma.ts b/packages/request-handler/src/uptime-kuma.ts index 39149c137..b9453eb76 100644 --- a/packages/request-handler/src/uptime-kuma.ts +++ b/packages/request-handler/src/uptime-kuma.ts @@ -1,11 +1,9 @@ -import dayjs from "dayjs"; - import { createIntegrationAsync } from "@homarr/integrations"; import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler< +export const uptimeKumaRequestHandler = createIntegrationRequestHandler< UptimeKumaDashboardData, "uptimeKuma", Record @@ -14,6 +12,4 @@ export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); }, - cacheDuration: dayjs.duration(1, "minute"), - queryKey: "uptimeKumaDashboard", }); diff --git a/packages/request-handler/src/vpn.ts b/packages/request-handler/src/vpn.ts index de068938c..ebca0fbeb 100644 --- a/packages/request-handler/src/vpn.ts +++ b/packages/request-handler/src/vpn.ts @@ -1,15 +1,13 @@ -import dayjs from "dayjs"; - import { createLogger } from "@homarr/core/infrastructure/logs"; import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { VpnSummary } from "@homarr/integrations/types"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; const logger = createLogger({ module: "vpnRequestHandler" }); -export const vpnSummaryHandler = createCachedIntegrationRequestHandler< +export const vpnSummaryHandler = createIntegrationRequestHandler< VpnSummary | null, IntegrationKindByCategory<"vpn">, Record @@ -19,9 +17,6 @@ export const vpnSummaryHandler = createCachedIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getSummaryAsync(); } catch (error) { - // The VPN control server is unreachable (e.g. the container is down). - // Return null so the widget can render an "unavailable" status instead of - // surfacing a hard error, and so it recovers automatically once it is back up. logger.warn("Failed to fetch VPN summary, marking as unavailable", { integrationId: integration.id, integrationKind: integration.kind, @@ -30,6 +25,4 @@ export const vpnSummaryHandler = createCachedIntegrationRequestHandler< return null; } }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "vpnSummary", }); diff --git a/packages/request-handler/src/weather.ts b/packages/request-handler/src/weather.ts index eb4828048..5f5a097a4 100644 --- a/packages/request-handler/src/weather.ts +++ b/packages/request-handler/src/weather.ts @@ -1,14 +1,11 @@ -import dayjs from "dayjs"; import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; -import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; +import { createWidgetRequestHandler } from "./lib/widget-request-handler"; -export const weatherRequestHandler = createCachedWidgetRequestHandler({ - queryKey: "weatherAtLocation", - widgetKind: "weather", +export const weatherRequestHandler = createWidgetRequestHandler({ async requestAsync(input: { latitude: number; longitude: number }) { const res = await withTimeoutAsync(async (signal) => { return await fetchWithTrustedCertificatesAsync( @@ -34,7 +31,6 @@ export const weatherRequestHandler = createCachedWidgetRequestHandler({ }), } satisfies Weather; }, - cacheDuration: dayjs.duration(1, "minute"), }); const atLocationOutput = z.object({ diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 530769f5d..f4cbf6f98 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1599,6 +1599,7 @@ "menu": { "label": { "settings": "Settings", + "refresh": "Refresh", "options": "Options", "actions": "Actions" } @@ -4544,89 +4545,14 @@ "disabled": "Disabled" }, "job": { - "minecraftServerStatus": { - "label": "Minecraft server status" - }, - "timetable": { - "label": "Timetable" - }, "iconsUpdater": { "label": "Icons Updater" }, "analytics": { "label": "Analytics" }, - "smartHomeEntityState": { - "label": "Smart Home Entity State" - }, "ping": { "label": "Pings" - }, - "mediaServer": { - "label": "Media Server" - }, - "mediaOrganizer": { - "label": "Media Organizers" - }, - "downloads": { - "label": "Downloads" - }, - "mediaRequestStats": { - "label": "Media Request Stats" - }, - "mediaRequestList": { - "label": "Media Request List" - }, - "rssFeeds": { - "label": "RSS feeds" - }, - "indexerManager": { - "label": "Indexer Manager" - }, - "healthMonitoring": { - "label": "Health Monitoring" - }, - "dnsHole": { - "label": "DNS Hole Data" - }, - "updateChecker": { - "label": "Update checker" - }, - "mediaTranscoding": { - "label": "Media transcoding" - }, - "networkController": { - "label": "Network Controller" - }, - "refreshNotifications": { - "label": "Notification Updater" - }, - "dockerContainers": { - "label": "Docker containers" - }, - "firewallCpu": { - "label": "Firewall CPU" - }, - "firewallMemory": { - "label": "Firewall Memory" - }, - "firewallVersion": { - "label": "Firewall Version" - }, - "firewallInterfaces": { - "label": "Firewall Interfaces" - }, - "weather": { - "label": "Weather" - }, - "tracearr": { - "label": "Tracearr" - }, - "umamiActiveVisitors": { - "label": "Umami Active Visitors" - }, - "vpn": { - "label": "VPN Status" } }, "interval": { diff --git a/packages/widgets/src/_inputs/widget-anchor-note-input.tsx b/packages/widgets/src/_inputs/widget-anchor-note-input.tsx index e045e7df0..e909477b5 100644 --- a/packages/widgets/src/_inputs/widget-anchor-note-input.tsx +++ b/packages/widgets/src/_inputs/widget-anchor-note-input.tsx @@ -30,9 +30,6 @@ export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetI { enabled: Boolean(integrationId), refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, }, ); diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index 7fa7182ae..675d56330 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -709,8 +709,6 @@ const RepositoryImportModal = createModal(({ innerProps, const docker = clientApi.docker.getContainers.useQuery(undefined, { refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, enabled: innerProps.isAdmin, }); diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 99585b838..90a1c998c 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -145,18 +145,10 @@ interface AnchorNoteWidgetContentProps { const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => { const t = useScopedI18n("widget.anchorNote"); - const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery( - { - integrationId, - noteId, - }, - { - staleTime: 15 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); + const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({ + integrationId, + noteId, + }); const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation(); const [isEditing, setIsEditing] = useState(false); diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 21ca86efd..56e079a8c 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -23,16 +23,9 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); - const { data: app } = clientApi.app.byId.useQuery( - { - id: options.appId, - }, - { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); + const { data: app } = clientApi.app.byId.useQuery({ + id: options.appId, + }); useRegisterSpotlightContextResults( `app-${app?.id ?? options.appId}`, app?.href diff --git a/packages/widgets/src/app/ping/ping-indicator.tsx b/packages/widgets/src/app/ping/ping-indicator.tsx index b8f88cdc8..75dc38974 100644 --- a/packages/widgets/src/app/ping/ping-indicator.tsx +++ b/packages/widgets/src/app/ping/ping-indicator.tsx @@ -11,20 +11,7 @@ interface PingIndicatorProps { export const PingIndicator = ({ appId }: PingIndicatorProps) => { const t = useI18n(); - const { data: pingResult } = clientApi.widget.app.ping.useQuery( - { id: appId }, - { refetchOnMount: false, refetchOnWindowFocus: false }, - ); - - const utils = clientApi.useUtils(); - clientApi.widget.app.updatedPing.useSubscription( - { id: appId }, - { - onData(data) { - utils.widget.app.ping.setData({ id: appId }, data); - }, - }, - ); + const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false }); if (!pingResult) return ; diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index dacac283d..032858c2f 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -28,20 +28,7 @@ const ArchiveTeamWarriorWidgetContent = ({ integrationId: string; options: WidgetComponentProps<"archiveTeamWarrior">["options"]; }) => { - const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ - integrationId, - }); - - const utils = clientApi.useUtils(); - - clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription( - { integrationId }, - { - onData: (next) => { - utils.widget.archiveTeamWarrior.getStatus.setData({ integrationId }, next); - }, - }, - ); + const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId }); const status = data.status; const projectName = status.project?.title ?? status.selectedProject ?? "No project selected"; diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index d9d8d26d3..9e410f527 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -7,10 +7,9 @@ import type { WidgetComponentProps } from "../definition"; import { AudioStatsContent } from "./audio-stats-content"; export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) { - const { data: response } = clientApi.widget.audioStats.getStats.useQuery( - { integrationId: integrationIds[0] ?? "" }, - { staleTime: 5 * 60 * 1000 }, - ); + const { data: response } = clientApi.widget.audioStats.getStats.useQuery({ + integrationId: integrationIds[0] ?? "", + }); if (!response) return ; diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 7c2bcc06c..3f934423d 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -41,36 +41,7 @@ export default function BeszelAlertsWidget({ () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), [integrationIds, options.showHistory, options.maxHistoryItems], ); - const { - data: results = [], - error: alertsError, - isPending, - } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, { - staleTime: 10_000, - gcTime: 48 * 60 * 60 * 1000, - refetchInterval: 10_000, - retry: false, - }); - const utils = clientApi.useUtils(); - - clientApi.widget.beszel.subscribeAlerts.useSubscription(alertsInput, { - enabled: !isEditMode, - onData(data) { - utils.widget.beszel.getAlerts.setData(alertsInput, (prev) => { - if (!prev) return prev; - return prev.map((r) => - r.integrationId === data.integrationId - ? { - ...r, - alerts: data.alerts.alerts, - history: data.alerts.history, - updatedAt: data.timestamp, - } - : r, - ); - }); - }, - }); + const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); const systemNameMap = useMemo(() => { const map: Record = {}; diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 3d5c3bd42..5d4671f5f 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -28,7 +28,7 @@ import type { WidgetComponentProps } from "../definition"; import type { BeszelSystemRow } from "../beszel/_shared/types"; import { statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; -import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; +import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; @@ -369,12 +369,8 @@ export default function BeszelSystemGridWidget({ data: results = [], error: systemsError, isPending, - } = clientApi.widget.beszel.getSystems.useQuery( - { integrationIds }, - { staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false }, - ); + } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - useBeszelSystemsSubscription(integrationIds, !isEditMode); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); if (systemsError) throw systemsError; diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 02d77a832..c82196639 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -67,10 +67,7 @@ export default function BeszelSystemStatsWidget({ data: systemsResult = [], isPending: systemsPending, error: systemsError, - } = clientApi.widget.beszel.getSystems.useQuery( - { integrationIds }, - { staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, retry: false }, - ); + } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); const systems = useMemo( () => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))), @@ -103,11 +100,8 @@ export default function BeszelSystemStatsWidget({ includeDocker: showDocker, }, { - staleTime: 10_000, - gcTime: 48 * 60 * 60 * 1000, - refetchInterval: isLive ? false : 10_000, + refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemReady && selectedSystem !== "", - retry: false, }, ); diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index 0b1cdf127..a184963e7 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -28,10 +28,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys data = [], isPending, isError, - } = clientApi.widget.beszel.getSystems.useQuery( - { integrationIds }, - { enabled: integrationIds.length > 0, staleTime: 30_000 }, - ); + } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { enabled: integrationIds.length > 0 }); const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))); return { data: selectData, isPending, isError }; }, diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 1d8c92355..49b3dca74 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -27,7 +27,7 @@ import type { WidgetComponentProps } from "../definition"; import type { BeszelSystemRow } from "../beszel/_shared/types"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; -import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; +import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; @@ -74,13 +74,9 @@ export default function BeszelSystemTableWidget({ data: results = [], error: systemsError, isPending, - } = clientApi.widget.beszel.getSystems.useQuery( - { integrationIds }, - { staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false }, - ); + } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); const size = getSizeConfig(width); - useBeszelSystemsSubscription(integrationIds, !isEditMode); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); const [sortStatus, setSortStatus] = useState>({ diff --git a/packages/widgets/src/beszel/_shared/hooks.ts b/packages/widgets/src/beszel/_shared/hooks.ts index ebb84b4eb..b00e6f857 100644 --- a/packages/widgets/src/beszel/_shared/hooks.ts +++ b/packages/widgets/src/beszel/_shared/hooks.ts @@ -2,30 +2,10 @@ import { useMemo } from "react"; -import { clientApi } from "@homarr/api/client"; - import type { BeszelSystemRow } from "./types"; type SystemRowWithKey = BeszelSystemRow & { _key: string }; -export const useBeszelSystemsSubscription = (integrationIds: string[], enabled = true) => { - const utils = clientApi.useUtils(); - clientApi.widget.beszel.subscribeSystems.useSubscription( - { integrationIds }, - { - enabled, - onData(data) { - utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => { - if (!prev) return prev; - return prev.map((r) => - r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r, - ); - }); - }, - }, - ); -}; - export const useBeszelFilteredSystems = ( results: { integrationId: string; systems: BeszelSystemRow[] }[], statusFilter: string, diff --git a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx index 52781c5af..1071ccc53 100644 --- a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx +++ b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx @@ -36,15 +36,12 @@ export const BeszelSystemStatsModal = createModal(( const t = useScopedI18n("widget.beszelSystemStats"); const [timePeriod, setTimePeriod] = useState("1h"); - const { data } = clientApi.widget.beszel.getSystemStats.useQuery( - { - integrationIds: [innerProps.integrationId], - systemId: innerProps.systemId, - timePeriod, - includeDocker: false, - }, - { staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000 }, - ); + const { data } = clientApi.widget.beszel.getSystemStats.useQuery({ + integrationIds: [innerProps.integrationId], + systemId: innerProps.systemId, + timePeriod, + includeDocker: false, + }); const mappers = useMemo( () => ({ diff --git a/packages/widgets/src/beszel/_shared/use-live-stats.ts b/packages/widgets/src/beszel/_shared/use-live-stats.ts index 440800d0d..b07bbbd2c 100644 --- a/packages/widgets/src/beszel/_shared/use-live-stats.ts +++ b/packages/widgets/src/beszel/_shared/use-live-stats.ts @@ -1,92 +1,15 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - import { clientApi } from "@homarr/api/client"; -import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; - -const MAX_BUFFER = 30; -const FLUSH_INTERVAL_MS = 3000; - -interface LiveStatsData { - systemStats: BeszelSystemStatsRecord[]; - containerStats: BeszelContainerStatsRecord[]; -} - -function appendToBuffer( - prev: LiveStatsData | null, - incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }, -): LiveStatsData { - const systemStats = [...(prev?.systemStats ?? []), incoming.stats].slice(-MAX_BUFFER); - const prevContainer = prev?.containerStats ?? []; - if (!incoming.containerStats) { - return { systemStats, containerStats: prevContainer }; - } - return { systemStats, containerStats: [...prevContainer, incoming.containerStats].slice(-MAX_BUFFER) }; -} export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => { - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const bufferRef = useRef(null); - const dirtyRef = useRef(false); - const generationRef = useRef(0); - - const integrationKey = [...integrationIds].sort().join(","); - - useEffect(() => { - generationRef.current += 1; - setData(null); - setError(null); - bufferRef.current = null; - dirtyRef.current = false; - }, [systemId, enabled, integrationKey]); - - const onData = useCallback( - (incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => { - const gen = generationRef.current; - queueMicrotask(() => { - if (generationRef.current !== gen) return; - setError(null); - const isFirst = !bufferRef.current; - bufferRef.current = appendToBuffer(bufferRef.current, incoming); - dirtyRef.current = true; - if (isFirst) { - setData(bufferRef.current); - } - }); - }, - [], + const { data, error } = clientApi.widget.beszel.getSystemStats.useQuery( + { integrationIds, systemId, timePeriod: "1m" as const, includeDocker: true }, + { enabled: enabled && systemId !== "", refetchInterval: 5_000 }, ); - const onError = useCallback((err: unknown) => { - if (err instanceof Error) { - setError(err); - return; - } - setError(new Error(String(err))); - }, []); - - const subscriptionInput = useMemo(() => ({ integrationIds, systemId }), [integrationKey, systemId]); - const subscriptionEnabled = enabled && systemId !== ""; - - useEffect(() => { - if (!subscriptionEnabled) return; - const gen = generationRef.current; - const id = setInterval(() => { - if (generationRef.current !== gen) return; - if (!dirtyRef.current || !bufferRef.current) return; - dirtyRef.current = false; - setData(bufferRef.current); - }, FLUSH_INTERVAL_MS); - return () => clearInterval(id); - }, [subscriptionEnabled, integrationKey, systemId]); - - clientApi.widget.beszel.subscribeSystemStats.useSubscription(subscriptionInput, { - enabled: subscriptionEnabled, - onData, - onError, - }); - - return { data, error }; + return { + data: data ? { systemStats: data.systemStats, containerStats: data.containerStats } : null, + error: error ?? (data?.error ? new Error(data.error) : null), + }; }; diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index fb0940720..b00cd1af0 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -16,15 +16,11 @@ import type { WidgetComponentProps } from "../definition"; import { CalendarDay } from "./calender-day"; import classes from "./component.module.css"; -export default function CalendarWidget( - props: WidgetComponentProps<"calendar">, -) { +export default function CalendarWidget(props: WidgetComponentProps<"calendar">) { const [month, setMonth] = useState(new Date()); if (props.integrationIds.length === 0) { - return ( - - ); + return ; } return ; @@ -35,13 +31,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> { setMonth: (date: Date) => void; } -const FetchCalendar = ({ - month, - setMonth, - isEditMode, - integrationIds, - options, -}: FetchCalendarProps) => { +const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => { const input = { integrationIds, month: month.getMonth(), @@ -49,42 +39,11 @@ const FetchCalendar = ({ releaseType: options.releaseType, showUnmonitored: options.showUnmonitored, }; - const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input, { - staleTime: 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }); + const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input); - const utils = clientApi.useUtils(); - clientApi.widget.calendar.subscribeToEvents.useSubscription(input, { - onData(data) { - utils.widget.calendar.findAllEvents.setData(input, (old) => { - return old?.map((item) => { - if (item.integration.id !== data.integration.id) return item; - return { - ...item, - events: data.events, - }; - }); - }); - }, - }); + const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); - const events = useMemo( - () => data?.flatMap((item) => item.events) ?? [], - [data], - ); - - return ( - - ); + return ; }; interface CalendarBaseProps { @@ -95,13 +54,7 @@ interface CalendarBaseProps { options: WidgetComponentProps<"calendar">["options"]; } -const CalendarBase = ({ - isEditMode, - events, - month, - setMonth, - options, -}: CalendarBaseProps) => { +const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => { const params = useParams(); const locale = params.locale as string; const { firstDayOfWeek } = useSettings(); @@ -182,14 +135,9 @@ const CalendarBase = ({ const eventsForDate = normalizedEvents .filter((event) => dayjs(event.startDate).isSame(tileDate, "day")) .filter( - (event) => - event.metadata?.type !== "radarr" || - options.releaseType.includes(event.metadata.releaseType), + (event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType), ) - .toSorted( - (eventA, eventB) => - eventA.startDate.getTime() - eventB.startDate.getTime(), - ); + .toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()); return ( { splitEvents.push({ ...event, startDate: currentStart.toDate(), - endDate: currentStart.endOf("day").isAfter(event.endDate) - ? event.endDate - : currentStart.endOf("day").toDate(), + endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(), }); currentStart = currentStart.add(1, "day").startOf("day"); diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index 91b37ecd4..46940f56e 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -31,27 +31,7 @@ interface CoolifyContentProps { } function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) { - const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery( - { integrationIds }, - { staleTime: 30 * 1000 }, - ); - - const utils = clientApi.useUtils(); - clientApi.widget.coolify.subscribeInstancesInfo.useSubscription( - { integrationIds }, - { - onData(newData) { - utils.widget.coolify.getInstancesInfo.setData({ integrationIds }, (prevData) => { - if (!prevData) return prevData; - return prevData.map((instance) => - instance.integrationId === newData.integrationId - ? { ...instance, instanceInfo: newData.instanceInfo, updatedAt: newData.timestamp } - : instance, - ); - }); - }, - }, - ); + const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds }); const isTiny = width < 256; const [firstInstance] = instancesData; diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 86b7a9a6a..f46c3d746 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -51,100 +51,16 @@ export default function DnsHoleControlsWidget({ .map(({ id }) => id) .filter((id) => integrationIds.includes(id)); - const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery( - { - integrationIds, - }, - { - staleTime: 5 * 1000, - refetchInterval: 5 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); + const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({ + integrationIds, + }); const utils = clientApi.useUtils(); - // Subscribe to summary updates - clientApi.widget.dnsHole.subscribeToSummary.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.dnsHole.summary.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) return undefined; - const newData = prevData.map((summary) => - summary.integration.id === data.integration.id - ? { - integration: { - ...summary.integration, - updatedAt: new Date(), - }, - summary: data.summary, - } - : summary, - ); - - return newData; - }, - ); - }, - }, - ); - - // Mutations for dnsHole state, set to undefined on click, and change again on settle const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({ - onSettled: (_, error, { integrationId }) => { - utils.widget.dnsHole.summary.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) return []; - - return prevData.map((item) => - item.integration.id === integrationId - ? { - ...item, - summary: { - ...item.summary, - status: error ? "disabled" : "enabled", - }, - } - : item, - ); - }, - ); - }, + onSettled: () => void utils.widget.dnsHole.summary.invalidate(), }); const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({ - onSettled: (_, error, { integrationId }) => { - utils.widget.dnsHole.summary.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) return []; - - return prevData.map((item) => - item.integration.id === integrationId - ? { - ...item, - summary: { - ...item.summary, - status: error ? "enabled" : "disabled", - }, - } - : item, - ); - }, - ); - }, + onSettled: () => void utils.widget.dnsHole.summary.invalidate(), }); const toggleDns = (integrationId: string) => { const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId); diff --git a/packages/widgets/src/dns-hole/summary/component.tsx b/packages/widgets/src/dns-hole/summary/component.tsx index 76e579d4e..4bcc82568 100644 --- a/packages/widgets/src/dns-hole/summary/component.tsx +++ b/packages/widgets/src/dns-hole/summary/component.tsx @@ -20,47 +20,12 @@ import type { widgetKind } from "."; import type { WidgetComponentProps, WidgetProps } from "../../definition"; export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps) { - const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery( - { - integrationIds, - }, - { - staleTime: 5 * 1000, - refetchInterval: 5 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - const utils = clientApi.useUtils(); + const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({ + integrationIds, + }); const t = useI18n(); - clientApi.widget.dnsHole.subscribeToSummary.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.dnsHole.summary.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) { - return undefined; - } - - const newData = prevData.map((item) => - item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, - ); - return newData; - }, - ); - }, - }, - ); - const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]); return ( diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index a0ec57a6f..42db221a2 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -184,20 +184,13 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo const t = useScopedI18n("docker"); const isTiny = width <= 256; - const utils = clientApi.useUtils(); const { data } = clientApi.docker.getContainers.useQuery(undefined, { - staleTime: 20 * 1000, + refetchInterval: 30_000, }); const containers = data?.containers ?? []; const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const relativeTime = useTimeAgo(timestamp); - clientApi.docker.subscribeContainers.useSubscription(undefined, { - onData(data) { - utils.docker.getContainers.setData(undefined, { containers: data, timestamp: new Date() }); - }, - }); - const totalContainers = containers.length; const totals = useMemo(() => { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index c74723d86..0edfb9ba5 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -88,19 +88,10 @@ export default function DownloadClientsWidget({ integrationIds.includes(id) ? [id] : [], ); - const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery( - { - integrationIds, - limitPerIntegration: options.limitPerIntegration, - }, - { - staleTime: 5 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - const utils = clientApi.useUtils(); + const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ + integrationIds, + limitPerIntegration: options.limitPerIntegration, + }); const t = useScopedI18n("widget.downloads"); const tCommon = useScopedI18n("common"); @@ -121,40 +112,13 @@ export default function DownloadClientsWidget({ return statuses; }, [currentItems]); - //Get API mutation functions - const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(); - const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(); - const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(); + const utils = clientApi.useUtils(); + const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; + const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(invalidateDownloads); + const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(invalidateDownloads); + const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(invalidateDownloads); - //Subscribe to dynamic data changes - clientApi.widget.downloads.subscribeToJobsAndStatuses.useSubscription( - { - integrationIds, - limitPerIntegration: options.limitPerIntegration, - }, - { - onData: (data) => { - utils.widget.downloads.getJobsAndStatuses.setData( - { integrationIds, limitPerIntegration: options.limitPerIntegration }, - (prevData) => { - return prevData?.map((item) => { - if (item.integration.id !== data.integration.id) return item; - - return { - data: data.data, - integration: { - ...data.integration, - updatedAt: new Date(), - }, - }; - }); - }, - ); - }, - }, - ); - - //Flatten Data array for which each element has it's integration, data (base + calculated) and actions. Memoized on data subscription + //Flatten Data array for which each element has its integration, data (base + calculated) and actions const data = useMemo( () => currentItems @@ -785,8 +749,10 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli "/s", ); - const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(); - const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(); + const utils = clientApi.useUtils(); + const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; + const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(invalidateDownloads); + const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads); const [opened, { open, close }] = useDisclosure(false); const t = useScopedI18n("widget.downloads"); return ( diff --git a/packages/widgets/src/firewall/component.tsx b/packages/widgets/src/firewall/component.tsx index 7318cd3d3..523eadd8d 100644 --- a/packages/widgets/src/firewall/component.tsx +++ b/packages/widgets/src/firewall/component.tsx @@ -29,10 +29,16 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget } }, []); - const firewallsCpuData = useUpdatingCpuStatus(integrationIds); - const firewallsMemoryData = useUpdatingMemoryStatus(integrationIds); - const firewallsVersionData = useUpdatingVersionStatus(integrationIds); - const firewallsInterfacesData = useUpdatingInterfacesStatus(integrationIds); + const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds }); + const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery({ + integrationIds, + }); + const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery({ + integrationIds, + }); + const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery({ + integrationIds, + }); const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined"; const isTiny = width < 256; @@ -182,168 +188,6 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget ); } -export const useUpdatingCpuStatus = (integrationIds: string[]) => { - const utils = clientApi.useUtils(); - const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery( - { - integrationIds, - }, - { - staleTime: 5 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - - clientApi.widget.firewall.subscribeFirewallCpuStatus.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.firewall.getFirewallCpuStatus.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) { - return undefined; - } - - return prevData.map((item) => - item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, - ); - }, - ); - }, - }, - ); - - return firewallsCpuData; -}; - -export const useUpdatingMemoryStatus = (integrationIds: string[]) => { - const utils = clientApi.useUtils(); - const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery( - { - integrationIds, - }, - { - staleTime: 15 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - - clientApi.widget.firewall.subscribeFirewallMemoryStatus.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.firewall.getFirewallMemoryStatus.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) { - return undefined; - } - - return prevData.map((item) => - item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, - ); - }, - ); - }, - }, - ); - - return firewallsMemoryData; -}; - -export const useUpdatingVersionStatus = (integrationIds: string[]) => { - const utils = clientApi.useUtils(); - const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery( - { - integrationIds, - }, - { - staleTime: 60 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - - clientApi.widget.firewall.subscribeFirewallVersionStatus.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.firewall.getFirewallVersionStatus.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) { - return undefined; - } - - return prevData.map((item) => - item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, - ); - }, - ); - }, - }, - ); - return firewallsVersionData; -}; - -export const useUpdatingInterfacesStatus = (integrationIds: string[]) => { - const utils = clientApi.useUtils(); - const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery( - { - integrationIds, - }, - { - staleTime: 30 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - - clientApi.widget.firewall.subscribeFirewallInterfacesStatus.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.firewall.getFirewallInterfacesStatus.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) { - return undefined; - } - return prevData.map((item) => - item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, - ); - }, - ); - }, - }, - ); - - return firewallsInterfacesData; -}; - export function formatBitsPerSec(bytes: number, decimals: number): string { if (bytes === 0) return "0 b/s"; diff --git a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx index daa0b40ec..1ac7922d5 100644 --- a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx +++ b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx @@ -35,27 +35,9 @@ export const ClusterHealthMonitoring = ({ width, }: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => { const t = useI18n(); - const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery( - { - integrationId, - }, - { - staleTime: 5 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - - const utils = clientApi.useUtils(); - clientApi.widget.healthMonitoring.subscribeClusterHealthStatus.useSubscription( - { integrationId }, - { - onData(data) { - utils.widget.healthMonitoring.getClusterHealthStatus.setData({ integrationId }, data); - }, - }, - ); + const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ + integrationId, + }); if (!healthData) return ; diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 405838cac..8b77b1d2f 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -52,39 +52,12 @@ export const SystemHealthMonitoring = ({ width, }: WidgetComponentProps<"healthMonitoring">) => { const t = useI18n(); - const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery( - { - integrationIds, - }, - { - staleTime: 5 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); + const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ + integrationIds, + }); const [opened, { open, close }] = useDisclosure(false); - const utils = clientApi.useUtils(); const board = useRequiredBoard(); - clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription( - { integrationIds }, - { - onData(data) { - utils.widget.healthMonitoring.getSystemHealthStatus.setData({ integrationIds }, (prevData) => { - if (!prevData) { - return undefined; - } - return prevData.map((item) => - item.integrationId === data.integrationId - ? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp } - : item, - ); - }); - }, - }, - ); - const isTiny = width < 256; if (healthData.length === 0) return ; diff --git a/packages/widgets/src/immich/album-carousel/component.tsx b/packages/widgets/src/immich/album-carousel/component.tsx index d74144503..ee7085757 100644 --- a/packages/widgets/src/immich/album-carousel/component.tsx +++ b/packages/widgets/src/immich/album-carousel/component.tsx @@ -23,7 +23,7 @@ export default function ImmichAlbumCarouselWidget({ integrationId: integrationIds[0] ?? "", albumId: options.albumId ?? "", }, - { enabled: Boolean(options.albumId), staleTime: 15 * 60 * 1000 }, + { enabled: Boolean(options.albumId) }, ); const photoAssets = useMemo(() => { diff --git a/packages/widgets/src/immich/server-stats/component.tsx b/packages/widgets/src/immich/server-stats/component.tsx index e3e1596ea..919b0caf0 100644 --- a/packages/widgets/src/immich/server-stats/component.tsx +++ b/packages/widgets/src/immich/server-stats/component.tsx @@ -17,12 +17,9 @@ export default function ImmichServerStatsWidget({ options, }: WidgetComponentProps<"immich-serverStats">) { const t = useI18n(); - const { data: stats } = clientApi.widget.immich.getServerStats.useQuery( - { - integrationId: integrationIds[0] ?? "", - }, - { staleTime: 15 * 60 * 1000 }, - ); + const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({ + integrationId: integrationIds[0] ?? "", + }); if (!stats) return ; diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx index 51ddb1805..4e3fb7c7e 100644 --- a/packages/widgets/src/indexer-manager/component.tsx +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -18,33 +18,13 @@ export default function IndexerManagerWidget({ height, }: WidgetComponentProps<"indexerManager">) { const t = useI18n(); - const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery( - { integrationIds }, - { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); + const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds }); + const utils = clientApi.useUtils(); - - const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation(); + const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation({ + onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(), + }); const board = useRequiredBoard(); - - clientApi.widget.indexerManager.subscribeIndexersStatus.useSubscription( - { integrationIds }, - { - onData(newData) { - utils.widget.indexerManager.getIndexersStatus.setData({ integrationIds }, (previousData) => - previousData?.map((item) => - item.integrationId === newData.integrationId ? { ...item, indexers: newData.indexers } : item, - ), - ); - }, - }, - ); - const hasSmallWidth = width < 256; const hasSmallHeight = height < 256; diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 61fc9216d..05ba18fe5 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -6,7 +6,7 @@ import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { getMantineColor } from "@homarr/common"; +import { getMantineColor, toValidDate } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; import type { MediaRelease } from "@homarr/integrations/types"; import { mediaTypeConfigurations } from "@homarr/integrations/types"; @@ -18,12 +18,9 @@ import { OverflowBadge } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) { - const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery( - { - integrationIds, - }, - { staleTime: 5 * 60 * 1000 }, - ); + const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ + integrationIds, + }); return ( @@ -42,6 +39,19 @@ interface ItemProps { options: WidgetComponentProps<"mediaReleases">["options"]; } +const formatReleaseDate = (value: unknown, locale: string) => { + const date = toValidDate(value); + if (!date) return "—"; + return Intl.DateTimeFormat(locale, { + month: "2-digit", + year: "numeric", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +}; + const Item = ({ item, options }: ItemProps) => { const locale = useCurrentLocale(); const t = useI18n(); @@ -94,17 +104,7 @@ const Item = ({ item, options }: ItemProps) => { )} - + {length !== undefined && ( <> diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 3bc24cf70..bbbd0047e 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -19,6 +19,7 @@ import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react"; import type { RouterInputs, RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; +import { toValidDate } from "@homarr/common"; import type { MediaRequestStatus } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { openMediaRequestSearch } from "@homarr/spotlight"; @@ -35,44 +36,9 @@ export default function MediaServerWidget({ options, width, }: WidgetComponentProps<"mediaRequests-requestList">) { - const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery( - { - integrationIds, - }, - { - staleTime: 60 * 1000, - refetchOnWindowFocus: 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 filteredData = prevData.filter(({ integrationId }) => integrationId !== data.integrationId); - const newData = filteredData.concat( - data.requests.map((request) => ({ ...request, integrationId: data.integrationId })), - ); - return newData.toSorted((dataA, dataB) => { - if (dataA.status === dataB.status) { - return dataB.createdAt.getTime() - dataA.createdAt.getTime(); - } - - return ( - mediaRequestStatusConfiguration[dataA.status].position - - mediaRequestStatusConfiguration[dataB.status].position - ); - }); - }); - }, - }, - ); + const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ + integrationIds, + }); if (!mediaRequests) return ; if (mediaRequests.length === 0) throw new NoIntegrationDataError(); @@ -169,7 +135,7 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) = - {request.airDate?.getFullYear() ?? t("toBeDetermined")} + {toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")} {!isTiny && ( { - const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation(); + const utils = clientApi.useUtils(); + const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation({ + onSettled: () => void utils.widget.mediaRequests.invalidate(), + }); const t = useScopedI18n("widget.mediaRequests-requestList"); const handleDecision = (answer: RouterInputs["widget"]["mediaRequests"]["answerRequest"]["answer"]) => { mutateRequestAnswer({ diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 51a3e3d5e..5348b845a 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -36,16 +36,9 @@ export default function MediaServerWidget({ width, }: WidgetComponentProps<"mediaRequests-requestStats">) { const t = useScopedI18n("widget.mediaRequests-requestStats"); - const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery( - { - integrationIds, - }, - { - staleTime: 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - ); + const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({ + integrationIds, + }); const board = useRequiredBoard(); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index efa2c2415..e63c12a90 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -18,23 +18,11 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; -export default function MediaServerWidget({ - options, - integrationIds, - isEditMode, -}: WidgetComponentProps<"mediaServer">) { - const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery( - { - integrationIds, - showOnlyPlaying: options.showOnlyPlaying, - }, - { - staleTime: 5 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - ); - const utils = clientApi.useUtils(); +export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) { + const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ + integrationIds, + showOnlyPlaying: options.showOnlyPlaying, + }); const t = useScopedI18n("widget.mediaServer"); const columns = useMemo[]>( @@ -83,32 +71,6 @@ export default function MediaServerWidget({ [t], ); - clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription( - { - integrationIds, - showOnlyPlaying: options.showOnlyPlaying, - }, - { - enabled: !isEditMode, - onData(data) { - utils.widget.mediaServer.getCurrentStreams.setData( - { integrationIds, showOnlyPlaying: options.showOnlyPlaying }, - (previousData) => { - return previousData?.map((pair) => { - if (pair.integrationId === data.integrationId) { - return { - ...pair, - sessions: data.data, - }; - } - return pair; - }); - }, - ); - }, - }, - ); - // Only render the flat list of sessions when the currentStreams change // Otherwise it will always create a new array reference and cause the table to re-render const flatSessions = useMemo( diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index 8b0ba6591..fe4873e45 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -38,18 +38,7 @@ export default function MediaTranscodingWidget({ pageSize: queuePageSize, page: queuePage, }; - const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input, { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); - - const utils = clientApi.useUtils(); - clientApi.widget.mediaTranscoding.subscribeData.useSubscription(input, { - onData(data) { - utils.widget.mediaTranscoding.getDataAsync.setData(input, data); - }, - }); + const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input); const [view, setView] = useState(options.defaultView); const t = useI18n("widget.mediaTranscoding"); diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx index ddf4d6e4b..20214e3ac 100644 --- a/packages/widgets/src/minecraft/server-status/component.tsx +++ b/packages/widgets/src/minecraft/server-status/component.tsx @@ -11,18 +11,7 @@ import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) { - const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options, { - staleTime: 5 * 60 * 1000, - }); - const utils = clientApi.useUtils(); - clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, { - onData(data) { - utils.widget.minecraft.getServerStatus.setData(options, { - data, - timestamp: new Date(), - }); - }, - }); + const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options); const tStatus = useScopedI18n("widget.minecraftServerStatus.status"); if (!result) return ; diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index 846d1128f..c4e892dba 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -21,48 +21,9 @@ export default function NetworkControllerNetworkStatusWidget({ options, integrationIds, }: WidgetComponentProps<"networkControllerStatus">) { - const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery( - { - integrationIds, - }, - { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - const utils = clientApi.useUtils(); - - clientApi.widget.networkController.subscribeToSummary.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.networkController.summary.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) { - return undefined; - } - - return prevData.map((item) => - item.integration.id === data.integration.id - ? { - ...item, - summary: data.summary, - updatedAt: new Date(), - } - : item, - ); - }, - ); - }, - }, - ); + const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({ + integrationIds, + }); const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]); diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx index 21a4b6a93..cb5dc7545 100644 --- a/packages/widgets/src/network-controller/summary/component.tsx +++ b/packages/widgets/src/network-controller/summary/component.tsx @@ -20,45 +20,12 @@ dayjs.extend(duration); export default function NetworkControllerSummaryWidget({ integrationIds, }: WidgetComponentProps<"networkControllerSummary">) { - const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery( - { - integrationIds, - }, - { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - const utils = clientApi.useUtils(); + const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({ + integrationIds, + }); const t = useI18n(); - clientApi.widget.networkController.subscribeToSummary.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.networkController.summary.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) { - return undefined; - } - - return prevData.map((item) => - item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, - ); - }, - ); - }, - }, - ); - const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]); return ( diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index 47aa829be..3b7cc5ba7 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -12,43 +12,10 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) { - const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery( - { - ...options, - integrationIds, - }, - { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - const utils = clientApi.useUtils(); - - clientApi.widget.notifications.subscribeNotifications.useSubscription( - { - ...options, - integrationIds, - }, - { - onData: (data) => { - utils.widget.notifications.getNotifications.setData({ ...options, integrationIds }, (prevData) => { - return prevData?.map((item) => { - if (item.integration.id !== data.integration.id) return item; - - return { - data: data.data, - integration: { - ...data.integration, - updatedAt: new Date(), - }, - }; - }); - }); - }, - }, - ); + const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({ + ...options, + integrationIds, + }); const t = useScopedI18n("widget.notifications"); diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx index 3b476aa0d..08bcee3fb 100644 --- a/packages/widgets/src/paperless-ngx/component.tsx +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -72,10 +72,9 @@ const heroPartVisibility = { export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) { const t = useScopedI18n("widget.paperlessNgx"); - const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery( - { integrationId: integrationIds[0] ?? "" }, - { staleTime: 5 * 60 * 1000 }, - ); + const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({ + integrationId: integrationIds[0] ?? "", + }); if (!stats) return ; diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index a2cf07ebb..626f87328 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -12,29 +12,7 @@ import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.scss"; const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { - const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input, { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }); - const utils = clientApi.useUtils(); - - clientApi.widget.rssFeed.subscribeFeeds.useSubscription(input, { - onData(updatedData) { - utils.widget.rssFeed.getFeeds.setData(input, (oldData) => { - return oldData - ?.filter((entry) => entry.feedUrl !== updatedData.url) - .concat(updatedData.entries) - .toSorted((entryA, entryB) => { - return entryA.published && entryB.published - ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() - : 0; - }) - .slice(0, input.maximumAmountPosts); - }); - }, - }); + const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input); return feedEntries; }; diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 2ad45b7a3..f1d6b6e18 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -23,20 +23,13 @@ export default function SmartHomeEntityStateWidget({ entityId: options.entityId, integrationId, }; - const { data: entityState } = clientApi.widget.smartHome.entityState.useQuery(input, { - staleTime: 60 * 1000, - }); + const { data: entityState } = clientApi.widget.smartHome.entityState.useQuery(input); const utils = clientApi.useUtils(); - - clientApi.widget.smartHome.subscribeEntityState.useSubscription(input, { - onData(data) { - utils.widget.smartHome.entityState.setData(input, data.state); - }, + const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({ + onSettled: () => void utils.widget.smartHome.entityState.invalidate(input), }); - const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation(); - const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : ""; const handleClick = useCallback(() => { diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx index dcde145a4..4b2b83dbb 100644 --- a/packages/widgets/src/speedtest-tracker/component.tsx +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -14,34 +14,9 @@ import { mergeStats } from "./helpers"; import { LatestResultSection } from "./latest-result"; import { RecentResultsSection } from "./recent-results"; -export default function SpeedtestTrackerWidget({ - options, - integrationIds, - isEditMode, -}: WidgetComponentProps<"speedtestTracker">) { +export default function SpeedtestTrackerWidget({ options, integrationIds }: WidgetComponentProps<"speedtestTracker">) { const t = useScopedI18n("widget.speedtestTracker"); - const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery( - { integrationIds }, - { staleTime: 10 * 60 * 1000 }, - ); - - const utils = clientApi.useUtils(); - clientApi.widget.speedtestTracker.subscribeToDashboard.useSubscription( - { integrationIds }, - { - enabled: !isEditMode, - onData(newData) { - utils.widget.speedtestTracker.getDashboard.setData({ integrationIds }, (prevData) => { - if (!prevData) return prevData; - return prevData.map((instance) => - instance.integrationId === newData.integrationId - ? { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp } - : instance, - ); - }); - }, - }, - ); + const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds }); const combined = useMemo( () => diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx index 99078b4d2..918851ce8 100644 --- a/packages/widgets/src/stocks/component.tsx +++ b/packages/widgets/src/stocks/component.tsx @@ -25,9 +25,7 @@ function calculateChangePercentage(currentPrice: number, previousClose: number) export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) { const t = useScopedI18n("widget.stockPrice"); const theme = useMantineTheme(); - const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options, { - staleTime: 5 * 60 * 1000, - }); + const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options); if (!result) return ; const { data } = result; diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx index efe2fe73d..d24a68c37 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -118,18 +118,7 @@ const SystemDiskCard = ({ export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) { const queryInput = { integrationIds }; - const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput, { - staleTime: 5 * 1000, - }); - const utils = clientApi.useUtils(); - - clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, { - onData(data) { - utils.widget.healthMonitoring.getSystemHealthStatus.setData(queryInput, (oldData) => - oldData?.map((item) => (item.integrationId === data.integrationId ? { ...item, ...data } : item)), - ); - }, - }); + const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput); const lastItem = data.at(-1); diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index d9e5a8604..1d3854c89 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Box, Group, Stack } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; @@ -15,56 +15,49 @@ import { NetworkTrafficChart } from "./chart/network-traffic"; const MAX_QUEUE_SIZE = 15; +const toChartItem = (healthInfo: { + cpuUtilization: number; + memUsedInBytes: number; + gpu: { processorUtilization: number }[]; + network: { up: number; down: number } | null; +}) => ({ + cpu: healthInfo.cpuUtilization, + memory: healthInfo.memUsedInBytes, + gpu: + healthInfo.gpu.length > 0 + ? healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / healthInfo.gpu.length + : 0, + network: healthInfo.network, +}); + export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) { const { ref, width } = useElementSize(); - const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery( - { - integrationIds, - }, - { - staleTime: 5 * 1000, - }, - ); + const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ + integrationIds, + }); const memoryCapacityInBytes = (data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0); const [items, setItems] = useState< { cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[] - >( - data.map((item) => ({ - cpu: item.healthInfo.cpuUtilization, - memory: item.healthInfo.memUsedInBytes, - gpu: - item.healthInfo.gpu.length > 0 - ? item.healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / item.healthInfo.gpu.length - : 0, - network: item.healthInfo.network, - })), - ); + >(() => (data[0] ? [toChartItem(data[0].healthInfo)] : [])); - clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription( - { - integrationIds, - }, - { - onData(data) { - setItems((previousItems) => { - const next = { - cpu: data.healthInfo.cpuUtilization, - memory: data.healthInfo.memUsedInBytes, - gpu: - data.healthInfo.gpu.length > 0 - ? data.healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / data.healthInfo.gpu.length - : 0, - network: data.healthInfo.network, - }; + const prevIntegrationIds = useRef(integrationIds); + useEffect(() => { + if (prevIntegrationIds.current === integrationIds) return; + prevIntegrationIds.current = integrationIds; + setItems([]); + }, [integrationIds]); - return [...previousItems, next].slice(-MAX_QUEUE_SIZE); - }); - }, - }, - ); + const lastUpdatedAt = useRef(dataUpdatedAt); + useEffect(() => { + if (dataUpdatedAt === lastUpdatedAt.current) return; + lastUpdatedAt.current = dataUpdatedAt; + const firstItem = data[0]; + if (!firstItem) return; + setItems((prev) => [...prev, toChartItem(firstItem.healthInfo)].slice(-MAX_QUEUE_SIZE)); + }, [dataUpdatedAt, data]); const showNetwork = items.length === 0 || (items.every((item) => item.network !== null) && options.visibleCharts.includes("network")); diff --git a/packages/widgets/src/timetable/component.tsx b/packages/widgets/src/timetable/component.tsx index 25eaa3d43..47751a751 100644 --- a/packages/widgets/src/timetable/component.tsx +++ b/packages/widgets/src/timetable/component.tsx @@ -23,16 +23,11 @@ interface TimetableWidgetInnerProps { } const TimetableWidgetInner = ({ station, baseUrl }: TimetableWidgetInnerProps) => { - const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery( - { - baseUrl, - stationId: station.value, - limit: 10, - }, - { - staleTime: 60 * 1000, - }, - ); + const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery({ + baseUrl, + stationId: station.value, + limit: 10, + }); const t = useScopedI18n("widget.timetable"); return ( diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 0ff5e9132..f3b70192a 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -13,50 +13,23 @@ import { StatsBar } from "./stats-section"; import { StreamsList } from "./streams-section"; import { ViolationsList } from "./violations-section"; -export default function TracearrWidget({ - options, - integrationIds, - width, - isEditMode, -}: WidgetComponentProps<"tracearr">) { +export default function TracearrWidget({ options, integrationIds, width }: WidgetComponentProps<"tracearr">) { if (integrationIds.length === 0) { throw new NoIntegrationDataError(); } - return ; + return ; } interface TracearrContentProps { integrationIds: string[]; options: WidgetComponentProps<"tracearr">["options"]; width: number; - isEditMode: boolean; } -function TracearrContent({ integrationIds, options, width, isEditMode }: TracearrContentProps) { +function TracearrContent({ integrationIds, options, width }: TracearrContentProps) { const t = useScopedI18n("widget.tracearr"); - const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery( - { integrationIds }, - { staleTime: 5 * 1000 }, - ); - - const utils = clientApi.useUtils(); - clientApi.widget.tracearr.subscribeToDashboard.useSubscription( - { integrationIds }, - { - enabled: !isEditMode, - onData(newData) { - utils.widget.tracearr.getDashboard.setData({ integrationIds }, (prevData) => { - if (!prevData) return prevData; - return prevData.map((instance) => - instance.integrationId === newData.integrationId - ? { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp } - : instance, - ); - }); - }, - }, - ); + const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds }); // Merge data from all integrations const combined = dashboardData.reduce( diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index 2f8dea22c..2a187c3d5 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -10,7 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { UmamiEventsContent } from "./umami-events-content"; import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list"; -import { formatTimeFrameLabel, formatXLabel } from "./umami-utils"; +import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils"; import type { TimeFrame } from "./umami-utils"; interface UmamiContentProps { @@ -41,29 +41,19 @@ export function UmamiContent({ const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery( - { - integrationIds, - websiteId, - timeFrame, - eventName, - }, - { staleTime: 5 * 60 * 1000 }, + { integrationIds, websiteId, timeFrame, eventName }, + umamiQueryOptions, ); const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId }; - const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(activeVisitorsInput, { - staleTime: 30 * 1000, - }); - const utils = clientApi.useUtils(); - clientApi.widget.umami.subscribeActiveVisitors.useSubscription(activeVisitorsInput, { - onData(count) { - utils.widget.umami.getActiveVisitors.setData(activeVisitorsInput, () => count); - }, - }); + const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery( + activeVisitorsInput, + umamiQueryOptions, + ); const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery( { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() }, - { enabled: viewMode === "events" && eventNames.length > 0, staleTime: 5 * 60 * 1000 }, + { enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions }, ); const multiEventTotal = multiEventSeries diff --git a/packages/widgets/src/umami/umami-events-content.tsx b/packages/widgets/src/umami/umami-events-content.tsx index 0351e906c..ef91df422 100644 --- a/packages/widgets/src/umami/umami-events-content.tsx +++ b/packages/widgets/src/umami/umami-events-content.tsx @@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client"; import type { UmamiEventSeries } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; -import { EVENT_COLORS, formatXLabel } from "./umami-utils"; +import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils"; interface UmamiEventsContentProps { integrationIds: string[]; @@ -29,13 +29,8 @@ export function UmamiEventsContent({ const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery( - { - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - eventNames: [...eventNames].toSorted(), - }, - { staleTime: 5 * 60 * 1000 }, + { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() }, + umamiQueryOptions, ); if (eventNames.length === 0) { diff --git a/packages/widgets/src/umami/umami-top-list.tsx b/packages/widgets/src/umami/umami-top-list.tsx index 125115f0c..3ce4b8730 100644 --- a/packages/widgets/src/umami/umami-top-list.tsx +++ b/packages/widgets/src/umami/umami-top-list.tsx @@ -6,6 +6,8 @@ import { clientApi } from "@homarr/api/client"; import type { UmamiMetricItem } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; +import { umamiQueryOptions } from "./umami-utils"; + interface UmamiTopListProps { integrationIds: string[]; websiteId: string; @@ -16,13 +18,8 @@ interface UmamiTopListProps { export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) { const t = useScopedI18n("widget.umami"); const { data = [] } = clientApi.widget.umami.getTopPages.useQuery( - { - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - limit, - }, - { staleTime: 5 * 60 * 1000 }, + { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit }, + umamiQueryOptions, ); return ; } @@ -30,13 +27,8 @@ export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, lim export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) { const t = useScopedI18n("widget.umami"); const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery( - { - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - limit, - }, - { staleTime: 5 * 60 * 1000 }, + { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit }, + umamiQueryOptions, ); return ( = { type UpsLayout = "mini" | "compact" | "full"; -export default function UpsWidget({ options, integrationIds, isEditMode, width }: WidgetComponentProps<"ups">) { +export default function UpsWidget({ options, integrationIds, width }: WidgetComponentProps<"ups">) { if (integrationIds.length === 0) { throw new NoIntegrationSelectedError(); } - return ; + return ; } interface UpsContentProps { integrationIds: string[]; options: WidgetComponentProps<"ups">["options"]; - isEditMode: boolean; width: number; } -function UpsContent({ integrationIds, options, isEditMode, width }: UpsContentProps) { +function UpsContent({ integrationIds, options, width }: UpsContentProps) { const t = useScopedI18n("widget.ups"); - const { data } = clientApi.widget.ups.getSummaries.useQuery({ integrationIds }, { staleTime: 30 * 1000 }); - - const utils = clientApi.useUtils(); - clientApi.widget.ups.subscribeSummaries.useSubscription( - { integrationIds }, - { - enabled: !isEditMode, - onData(newData) { - utils.widget.ups.getSummaries.setData({ integrationIds }, (prevData) => { - if (!prevData) return prevData; - return prevData.map((instance) => - instance.integrationId === newData.integrationId - ? { ...instance, summaries: newData.summaries, updatedAt: newData.timestamp } - : instance, - ); - }); - }, - }, - ); + const { data } = clientApi.widget.ups.getSummaries.useQuery({ integrationIds }); if (!data) return ; diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 2a3b6136b..5719f2f13 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -85,51 +85,23 @@ const heroVariantByRing = { false: classes.heroTextOnly, } as const; -export default function UptimeKumaWidget({ - integrationIds, - isEditMode, - options, - width, -}: WidgetComponentProps<"uptimeKuma">) { +export default function UptimeKumaWidget({ integrationIds, options, width }: WidgetComponentProps<"uptimeKuma">) { if (integrationIds.length === 0) { throw new NoIntegrationDataError(); } - return ; + return ; } interface UptimeKumaContentProps { integrationIds: string[]; - isEditMode: boolean; options: WidgetComponentProps<"uptimeKuma">["options"]; width: number; } -function UptimeKumaContent({ integrationIds, isEditMode, options, width }: UptimeKumaContentProps) { +function UptimeKumaContent({ integrationIds, options, width }: UptimeKumaContentProps) { const t = useScopedI18n("widget.uptimeKuma"); - const { data: dashboardData } = clientApi.widget.uptimeKuma.getDashboard.useQuery( - { integrationIds }, - { staleTime: 30 * 1000 }, - ); - - const utils = clientApi.useUtils(); - clientApi.widget.uptimeKuma.subscribeToDashboard.useSubscription( - { integrationIds }, - { - enabled: !isEditMode, - onData(newData) { - utils.widget.uptimeKuma.getDashboard.setData({ integrationIds }, (prevData) => { - if (!prevData) return prevData; - return prevData.map((instance) => { - if (instance.integrationId === newData.integrationId) { - return { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp }; - } - return instance; - }); - }); - }, - }, - ); + const { data: dashboardData } = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds }); if (!dashboardData) return ; diff --git a/packages/widgets/src/vpn/component.tsx b/packages/widgets/src/vpn/component.tsx index 85ee4e3cf..79ed5d695 100644 --- a/packages/widgets/src/vpn/component.tsx +++ b/packages/widgets/src/vpn/component.tsx @@ -1,53 +1,20 @@ "use client"; -import React from "react"; import { Flex, ScrollArea, Stack } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import type { WidgetComponentProps } from "../definition"; -import { updateVpnInfoFromSubscription } from "./helpers"; import { VpnIntegrationCard } from "./vpn-card"; export default function VpnWidget({ options, integrationIds }: WidgetComponentProps<"vpn">) { - const [integrations] = clientApi.widget.vpn.getSummaries.useSuspenseQuery( - { - ...options, - integrationIds, - }, - { - // Initial load only. Live updates arrive via the subscription below, which is - // fed by the VPN cron job publishing to the integration channel. - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); - const utils = clientApi.useUtils(); - - clientApi.widget.vpn.subscribeSummaries.useSubscription( - { - integrationIds, - }, - { - onData: (data) => { - utils.widget.vpn.getSummaries.setData( - { - ...options, - integrationIds, - }, - (prevData) => updateVpnInfoFromSubscription(prevData, data), - ); - }, - }, - ); - - if (integrations.length === 1) { - // It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const vpn = integrations[0]!; + const { data: integrations = [] } = clientApi.widget.vpn.getSummaries.useQuery({ + ...options, + integrationIds, + }); + const [vpn] = integrations; + if (integrations.length === 1 && vpn) { return ( diff --git a/packages/widgets/src/vpn/helpers.ts b/packages/widgets/src/vpn/helpers.ts index 24a81720b..9bc801429 100644 --- a/packages/widgets/src/vpn/helpers.ts +++ b/packages/widgets/src/vpn/helpers.ts @@ -1,22 +1,5 @@ -import type { RouterOutputs } from "@homarr/api"; - export const RUNNING_STATUS = "running"; -type VpnInfoItem = RouterOutputs["widget"]["vpn"]["getSummaries"][number]; - export function getStatusColor(status: string) { return status === RUNNING_STATUS ? "green" : "red"; } - -export function updateVpnInfoFromSubscription( - prevData: VpnInfoItem[] | undefined, - data: { integration: { id: string }; summary: VpnInfoItem["summary"] }, -): VpnInfoItem[] | undefined { - if (!prevData) { - return undefined; - } - - return prevData.map((item) => - item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, - ); -} diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index a2e9778ca..6e76ef8f2 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -19,16 +19,7 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr latitude: options.location.latitude, longitude: options.location.longitude, }; - const { data: weather } = clientApi.widget.weather.atLocation.useQuery(input, { - staleTime: 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); - - const utils = clientApi.useUtils(); - clientApi.widget.weather.subscribeAtLocation.useSubscription(input, { - onData: (data) => utils.widget.weather.atLocation.setData(input, data), - }); + const { data: weather } = clientApi.widget.weather.atLocation.useQuery(input); if (!weather) return ; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b82f1a255..c7f345fb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,7 +126,7 @@ catalogs: '@tabler/icons-react': specifier: ^3.44.0 version: 3.44.0 - '@tanstack/query-persist-client-core': + '@tanstack/query-async-storage-persister': specifier: ^5.101.0 version: 5.101.0 '@tanstack/react-query': @@ -138,6 +138,9 @@ catalogs: '@tanstack/react-query-next-experimental': specifier: ^5.101.0 version: 5.101.0 + '@tanstack/react-query-persist-client': + specifier: ^5.101.0 + version: 5.101.0 '@testcontainers/mysql': specifier: ^12.0.3 version: 12.0.3 @@ -914,7 +917,7 @@ importers: '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) - '@tanstack/query-persist-client-core': + '@tanstack/query-async-storage-persister': specifier: 'catalog:' version: 5.101.0 '@tanstack/react-query': @@ -926,6 +929,9 @@ importers: '@tanstack/react-query-next-experimental': specifier: 'catalog:' version: 5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7) + '@tanstack/react-query-persist-client': + specifier: 'catalog:' + version: 5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(react@19.2.7) '@trpc/client': specifier: 'catalog:' version: 11.17.0(@trpc/server@11.17.0(typescript@6.0.3))(typescript@6.0.3) @@ -7262,6 +7268,9 @@ packages: resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==} engines: {node: '>=12'} + '@tanstack/query-async-storage-persister@5.101.0': + resolution: {integrity: sha512-8Y8pwMVPh4kqU9m+MhbDYBuub+i5T2M9P/0+Ae3KccX1BwMIp6AdFAx3zDgLoZcEVI830mbJYf0sjAgsawCWVw==} + '@tanstack/query-core@5.101.0': resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} @@ -7284,6 +7293,12 @@ packages: next: ^13 || ^14 || ^15 || ^16 react: ^18 || ^19 + '@tanstack/react-query-persist-client@5.101.0': + resolution: {integrity: sha512-AUcdBgz8V6sM9axzdqkVmWjYSOETkhr6yAZSBnEFyZT2jo6vkFq3UrpRuxGs6fmhKMWv8FA+ZJGcbaKPaoAElQ==} + peerDependencies: + '@tanstack/react-query': ^5.101.0 + react: ^18 || ^19 + '@tanstack/react-query@5.101.0': resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} peerDependencies: @@ -20879,6 +20894,11 @@ snapshots: dependencies: remove-accents: 0.5.0 + '@tanstack/query-async-storage-persister@5.101.0': + dependencies: + '@tanstack/query-core': 5.101.0 + '@tanstack/query-persist-client-core': 5.101.0 + '@tanstack/query-core@5.101.0': {} '@tanstack/query-devtools@5.101.0': {} @@ -20899,6 +20919,12 @@ snapshots: next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) react: 19.2.7 + '@tanstack/react-query-persist-client@5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/query-persist-client-core': 5.101.0 + '@tanstack/react-query': 5.101.0(react@19.2.7) + react: 19.2.7 + '@tanstack/react-query@5.101.0(react@19.2.7)': dependencies: '@tanstack/query-core': 5.101.0 @@ -27386,7 +27412,7 @@ snapshots: '@posthog/core': 1.38.0 '@posthog/types': 1.391.1 core-js: 3.49.0 - dompurify: 3.4.11 + dompurify: 3.4.10 fflate: 0.4.8 preact: 10.29.2 query-selector-shadow-dom: 1.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c6fcf0924..53147b2e0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -45,10 +45,11 @@ catalog: "@semantic-release/release-notes-generator": ^14.1.1 "@t3-oss/env-nextjs": ^0.13.11 "@tabler/icons-react": ^3.44.0 - "@tanstack/query-persist-client-core": ^5.101.0 + "@tanstack/query-async-storage-persister": ^5.101.0 "@tanstack/react-query": ^5.101.0 "@tanstack/react-query-devtools": ^5.101.0 "@tanstack/react-query-next-experimental": ^5.101.0 + "@tanstack/react-query-persist-client": ^5.101.0 "@testcontainers/mysql": ^12.0.3 "@testcontainers/postgresql": ^12.0.3 "@testcontainers/redis": ^12.0.3