Compare commits

...
Author SHA1 Message Date
ajnart 6c3b584bd2 style: auto-fix lint and format 2026-07-21 08:08:53 +00:00
ajnart e3cf680cbd fix(integrations): support UniFi controller default ports 2026-07-21 10:07:25 +02:00
ajnart 24989ddd16 style: auto-fix lint and format 2026-07-06 00:07:03 +00:00
ajnart 8cd9e8c826 fix(integrations): use Unifi default port 8443 when no port is given
The node-unifi library hardcodes https:// in its base URL and the
Unifi controller API only speaks HTTPS. When a user adds the
integration with a bare http:// or https:// URL, getPortFromUrl
returned 80 or 443, and the library attempted a TLS handshake on
the wrong port. The server's plain HTTP response caused
'EPROTO ... wrong version number'.

Derive the port from the user's explicit port, or fall back to 8443
(the integration's defaultPort). Closes #6207.
2026-07-06 02:05:44 +02:00
homarr-crowdin[bot] 5b91687912 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-07-05 01:57:18 +00:00
Thomas a3df31ff05 Revise Immich API key permissions in documentation
Updated API key permissions for Immich integration documentation.
2026-07-04 21:49:08 +02:00
homarr-renovate[bot] c5c9daf5a5 chore(deps): update dependency undici to v7.28.0 [security] (#6188)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 18:11:47 +00:00
homarr-renovate[bot] ab0e878fbf chore(deps): update dependency posthog-js to ^1.396.2 (#6189)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 16:20:03 +00:00
Thomas 885be1b9a1 fix(widget-secrets): add missing widget_secret migration and add error notifications (#6192)
* fix(widget-secrets): add missing widget_secret migration and add error notifications

The widget_secret table was defined in all three Drizzle schema files but
no SQL migration was ever generated to create it, causing setSecret to
fail with 'no such table: widget_secret' in production (drizzle-kit push
in development hid the problem).

- Add migration SQL for sqlite (0040), mysql (0042), postgresql (0008)
- Update journal entries for all three dialects
- Add onError handlers to setSecret/deleteSecret mutations so errors
  are shown as red notifications instead of silently swallowed

Fixes #6186

* fix(widget-secrets): add missing drizzle snapshots for merge-url and widget_secret migrations

The merge_custom_widget_url migration (0039/0041/0007) was committed
without a corresponding snapshot, breaking drizzle-kit generate for
all future migrations. This adds:
- 0039/0041/0007 snapshots reflecting the merge-url changes
  (base_url+endpoint -> url, add enabled column)
- 0040/0042/0008 snapshots reflecting the widget_secret table

All snapshot chains now match journal entries (41/43/9 entries each).
2026-07-04 17:57:40 +02:00
homarr-renovate[bot] 0f8a86fdde fix(deps): update tailwindcss monorepo to ^4.3.2 (#6190)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 14:55:47 +00:00
homarr-renovate[bot] 59c084bc57 chore(deps): update testcontainers-node monorepo to ^12.0.4 (#6185)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 12:18:18 +00:00
homarr-renovate[bot] f6cf262bab chore(deps): update dependency posthog-js to ^1.396.1 (#6183)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 10:27:08 +00:00
Releases Homarr ba860aacaa chore(release): 1.69.1 [skip ci] 2026-07-04 09:02:40 +00:00
homarr-releases[bot] 161dafbdb8 chore(release): automatic release v1.69.1 2026-07-04 09:01:34 +00:00
Thomas a382c75968 fix: unblock query cache mutations in demo mode and add cache buster (#6176) 2026-07-04 10:47:09 +02:00
homarr-renovate[bot] 529942e4da chore(deps): update dependency brace-expansion@>=1.0.0 <=1.1.11 to >=5.0.7 (#6173)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 07:55:55 +00:00
homarr-releases[bot] e20053b2c1 chore: update bug report template (#6134) 2026-07-04 09:09:59 +02:00
134106b82c Main (#6175)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-07-04 09:09:23 +02:00
homarr-crowdin[bot] 7635031fdf chore(lang): updated translations from crowdin (#6143) 2026-07-04 08:58:20 +02:00
Bergasha b663dae409 fix (Calendar Widget): Add superjson serialization to query cache persister (#6172) 2026-07-04 08:57:49 +02:00
homarr-renovate[bot] 2c2bdb6b76 chore(deps): update dependency brace-expansion@>=2.0.0 <=2.0.1 to >=5.0.7 (#6174)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 04:45:04 +00:00
homarr-renovate[bot] dee9cd7aac chore(deps): update dependency lucide-react to ^1.22.0 (#6171)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-03 20:27:36 +00:00
homarr-renovate[bot] 7c222bf5e8 chore(deps): update dependency fastify to ^5.9.0 (#6166)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-03 20:26:10 +00:00
homarr-renovate[bot] f914cc26be chore(deps): update mantine monorepo to ^9.4.1 (#6165)
* chore(deps): update mantine monorepo to ^9.4.1

* style: auto-fix lint and format

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-03 20:15:37 +00:00
Releases Homarr 6f47a05cc2 chore(release): 1.69.0 [skip ci] 2026-07-03 18:08:44 +00:00
homarr-releases[bot] d8d5894b28 chore(release): automatic release v1.69.0 2026-07-03 18:07:40 +00:00
homarr-renovate[bot] 9252fdc139 fix(deps): update dependency postcss to ^8.5.16 (#6169)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-03 16:36:25 +00:00
Thomas f19033e0d5 feat: simplify caching — TanStack Query as single source of truth (#6075) 2026-07-03 16:33:03 +02:00
Releases Homarr 46ae756d15 chore(release): 1.68.1 [skip ci] 2026-06-30 12:02:44 +00:00
Thomas 8b63799f1b Merge pull request #6124 from homarr-labs/fix/source-maps-memory-regression
fix: make production source maps opt-in to restore v1.67.0 memory baseline
2026-06-30 14:01:42 +02:00
ajnart 60a69e31ef fix: revert production source maps to restore v1.67.0 memory baseline
CodeRabbit review addressed:
- Removed the env-var gating (simpler: just remove source maps entirely)
- CI health check now fails fast if container never becomes healthy

Changes vs origin/main:
- next.config.ts: remove productionBrowserSourceMaps and serverSourceMaps
  (they default to false, restoring pre-v1.68.0 behavior)
- Dockerfile: remove .map copy step and NODE_OPTIONS=--enable-source-maps
- CI: add memory-regression job (<700MB guardrail)

Closes #6123
2026-06-29 15:13:08 +02:00
ajnart 57ef0af4a2 fix: make source maps opt-in to restore v1.67.0 memory baseline
Production source maps (--enable-source-maps) caused a ~50% memory increase
(600MB to 1000MB) in v1.68.0. Node.js loads all .map files into memory when
this flag is enabled, and Next.js server chunks generate hundreds of map files.

Changes:
- Guard productionBrowserSourceMaps and serverSourceMaps behind
  HOMARR_ENABLE_SOURCE_MAPS env var (default off)
- Guard .map copy step and NODE_OPTIONS in Dockerfile behind
  ENABLE_SOURCE_MAPS build arg (default false)
- run.sh checks HOMARR_ENABLE_SOURCE_MAPS before setting --enable-source-maps
- Add Docker-based memory regression CI job (<700MB threshold)

Closes #6123
2026-06-29 10:08:52 +02:00
Thomas 597b5fbc8d Merge pull request #6097 from homarr-labs/docs/update-helm-docs
Update Helm chart documentation
2026-06-27 13:24:44 +02:00
github-actions[bot] 5412577519 docs(helm): Update Helm chart documentation 2026-06-27 11:22:16 +00:00
243 changed files with 18254 additions and 8394 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.69.0
- 1.68.0 - 1.68.0
- 1.67.0 - 1.67.0
- 1.66.1 - 1.66.1
+1
View File
@@ -77,3 +77,4 @@ skills-lock.json
# docusaurus # docusaurus
apps/docs/.docusaurus apps/docs/.docusaurus
apps/docs/build apps/docs/build
.pnpm-store
+39
View File
@@ -1,3 +1,42 @@
## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04)
### Bug Fixes
* unblock query cache mutations in demo mode and add cache buster ([#6176](https://github.com/homarr-labs/homarr/issues/6176)) ([a382c75](https://github.com/homarr-labs/homarr/commit/a382c75968e73bafff55b5890b1e95f0779b50d3))
## [1.69.0](https://github.com/homarr-labs/homarr/compare/v1.68.1...v1.69.0) (2026-07-03)
### Features
* add per-widget secret storage for release provider authentication ([77884a3](https://github.com/homarr-labs/homarr/commit/77884a31794e12a6f7bc59d96c8f61aa429ca68d))
* **config:** enable pnpm global virtual store for worktree support ([#6098](https://github.com/homarr-labs/homarr/issues/6098)) ([3864727](https://github.com/homarr-labs/homarr/commit/3864727502a64052c4e3838468094b30bf04a133))
* **docker-widget:** add per-column visibility toggle ([#6093](https://github.com/homarr-labs/homarr/issues/6093)) ([0765262](https://github.com/homarr-labs/homarr/commit/076526293d13379004461633d7ba4c2e83bd5ffe))
* **docs:** addd in documentation for archiveteam warrior ([f66469d](https://github.com/homarr-labs/homarr/commit/f66469d79797a2da214588f4e8253b03633c59a5))
* simplify caching — TanStack Query as single source of truth ([#6075](https://github.com/homarr-labs/homarr/issues/6075)) ([f19033e](https://github.com/homarr-labs/homarr/commit/f19033e0d53d248aed678737f3d04d5d8c7815b5))
* **widgets:** right-click context menu with auto-generated toggle actions ([#6126](https://github.com/homarr-labs/homarr/issues/6126)) ([7e58858](https://github.com/homarr-labs/homarr/commit/7e58858353ae866efd657270d4bb16d2c7d3e551))
* **widgets:** show calendar events on hover with pin on click ([#2637](https://github.com/homarr-labs/homarr/issues/2637)) ([#6081](https://github.com/homarr-labs/homarr/issues/6081)) ([db3d588](https://github.com/homarr-labs/homarr/commit/db3d58821e4de8d46b110125d0fa36919765248f))
### Bug Fixes
* add id to repository schema and handle missing ids gracefully ([8747d5f](https://github.com/homarr-labs/homarr/commit/8747d5f93ee9a295aad3c7035e8c6c7080bf5ee6))
* address PR review - atomic upsert, MCP metadata, stale tokens, save UX ([757d598](https://github.com/homarr-labs/homarr/commit/757d59854e5ec82d91c053b8bc3d7eaf2e70efc4))
* address PR review findings for widget-only integration refactor ([c892731](https://github.com/homarr-labs/homarr/commit/c892731479b47fab6218bd1f52fa6eccbdaa371e))
* address review - paginate GH releases, GitLab tag_name, docs, test ([e687bc8](https://github.com/homarr-labs/homarr/commit/e687bc8c9277797acaa164a09c74b3808a2cb346))
* apply version filter to LinuxServer.io provider ([34d976e](https://github.com/homarr-labs/homarr/commit/34d976eeea09e396334ce8fce5c492c07e7dd32b))
* catch individual repo fetch failures to prevent batch crashes ([d893515](https://github.com/homarr-labs/homarr/commit/d893515b932608ea08c9c6e90b83f340e40f6f8e))
* compile versionRegex once and add fetch timeouts ([64c7512](https://github.com/homarr-labs/homarr/commit/64c75122833981a49e9c8bf1789d501faff6d62d))
* **deps:** update dependency postcss to ^8.5.16 ([#6169](https://github.com/homarr-labs/homarr/issues/6169)) ([9252fdc](https://github.com/homarr-labs/homarr/commit/9252fdc13971bd982224a31d7be577112f99ec45))
* dns-hole widgets showing stale disconnected state ([#6096](https://github.com/homarr-labs/homarr/issues/6096)) ([2443f1c](https://github.com/homarr-labs/homarr/commit/2443f1cd3ff6765a91783d60d42aa03d9ba63fcd))
* gracefully degrade when widget_secret table is missing or access denied ([30107e4](https://github.com/homarr-labs/homarr/commit/30107e4e76a454ff7402de9a8bc2e0dd6f422aa8))
* handle missing widgetSecrets table and add default releases repo ([af35a50](https://github.com/homarr-labs/homarr/commit/af35a50fa05fee2f0118e98727eb108b0200f762))
* **immich:** v3 API compatibility — use searchAssets for album assets, drop deviceAssetId ([#5977](https://github.com/homarr-labs/homarr/issues/5977)) ([a5ec880](https://github.com/homarr-labs/homarr/commit/a5ec880eec276e0814d2ab3ed7f9bc0b804d0465))
* quick wins — calendar, about, glances, admin UX, releases, iframe, notes ([#6116](https://github.com/homarr-labs/homarr/issues/6116)) ([f9da71a](https://github.com/homarr-labs/homarr/commit/f9da71a19bdfa589aafe26b164d0e0782208ac0a))
* restore ArchiveTeam Warrior as a proper integration ([8a5ad58](https://github.com/homarr-labs/homarr/commit/8a5ad5861ba4c2d0978f1fa3f6115a1c5974f8a2))
* **timetable:** restore station search by removing disabled query guard ([d670b69](https://github.com/homarr-labs/homarr/commit/d670b6939672932c1a3d17ab9913901491cb46e3))
* update CHANGES.md for ArchiveTeam Warrior restoration, remove typo translation key ([62baaf7](https://github.com/homarr-labs/homarr/commit/62baaf78130c4b74e899a948cee17097d8c02593))
* verbose error messages in all release providers ([2155f13](https://github.com/homarr-labs/homarr/commit/2155f13f245f601b99458e227b6e058fbfa44cd9))
* **widgets:** make downloads widget table background transparent ([#6025](https://github.com/homarr-labs/homarr/issues/6025)) ([73bed51](https://github.com/homarr-labs/homarr/commit/73bed51df895250f415efd7a87e721e403b3fd99))
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30) ## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
### Bug Fixes ### Bug Fixes
+20 -7
View File
@@ -39,13 +39,26 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
'Scroll down to "API Keys"', 'Scroll down to "API Keys"',
'Click "New API Key"', 'Click "New API Key"',
'Enter a name, e.g. "Homarr"', 'Enter a name, e.g. "Homarr"',
(<span> (
Select the following permissions:{" "}
<code>album.read</code>,{" "} <span>
<code>user.read</code>,{" "}
<code>server.statistics</code>,{" "} Select the following permissions:{" "}
<code>asset.download</code> <code>album.statistics</code>,{" "}
</span>), <code>album.read</code>,{" "}
<code>album.create</code>,{" "}
<code>user.read</code>,{" "}
<code>user.update</code>,{" "}
<code>asset.read</code>,{" "}
<code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "}
<code>server.statistics</code>,{" "}
<code>server.about</code>.
<br />
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span>
),Ï
'Create the integration in Homarr and copy the API key to there' 'Create the integration in Homarr and copy the API key to there'
] ]
}]} /> }]} />
@@ -1,40 +1,48 @@
--- ---
title: 'Unifi Controller' title: "Unifi Controller"
description: "Unifi Controller is a network management platform for Unifi devices." description: "Unifi Controller is a network management platform for Unifi devices."
hide_title: true hide_title: true
--- ---
import { IntegrationHeader } from '@site/src/components/integrations/header'; import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from '@site/src/components/integrations/adding'; import { AddingIntegration } from "@site/src/components/integrations/adding";
import {IntegrationSecrets} from '@site/src/components/integrations/secrets'; import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { IconTopologyFull } from "@tabler/icons-react"; import { IconTopologyFull } from "@tabler/icons-react";
import { unifiControllerIntegration } from '.'; import { unifiControllerIntegration } from ".";
import { networkControllerSummaryWidget } from '@site/docs/widgets/network-controller-summary'; import { networkControllerSummaryWidget } from "@site/docs/widgets/network-controller-summary";
import { networkControllerStatusWidget } from '@site/docs/widgets/network-controller-status'; import { networkControllerStatusWidget } from "@site/docs/widgets/network-controller-status";
<IntegrationHeader integration={unifiControllerIntegration} categories={["Network controller"]} />
<IntegrationHeader
integration={unifiControllerIntegration}
categories={['Network controller']}
/>
This integration is using the [node-unifi](https://www.npmjs.com/package/node-unifi) library under the hood to connect to the Unifi Controller. This integration is using the [node-unifi](https://www.npmjs.com/package/node-unifi) library under the hood to connect to the Unifi Controller.
Therfore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers. Therfore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers.
### Widgets & Capabilities ### Widgets & Capabilities
<IntegrationCapabilites <IntegrationCapabilites
items={[ items={[{ widget: networkControllerSummaryWidget }, { widget: networkControllerStatusWidget }]}
{ widget: networkControllerSummaryWidget },
{ widget: networkControllerStatusWidget}
]}
/> />
### Adding the integration ### Adding the integration
<AddingIntegration /> <AddingIntegration />
Use the local HTTPS address of your controller:
- UniFi OS consoles such as Dream Router, Dream Machine, Cloud Gateway, and CloudKey use `https://<host>` (port 443).
- Self-hosted UniFi Network Servers normally use `https://<host>:8443`.
- If you omit the port, Homarr tries port 443 first and then port 8443 when the first port cannot be reached.
Use a local administrator account. Cloud-only accounts and accounts with multi-factor authentication are not supported by the underlying client.
### Secrets ### Secrets
<IntegrationSecrets secrets={[{
credentials: ['username', 'password'], <IntegrationSecrets
steps: [] secrets={[
}]} /> {
credentials: ["username", "password"],
steps: [],
},
]}
/>
+3 -3
View File
@@ -32,19 +32,19 @@
"@scalar/api-reference-react": "catalog:", "@scalar/api-reference-react": "catalog:",
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2", "@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:", "@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.1", "@tailwindcss/postcss": "^4.3.2",
"clsx": "catalog:", "clsx": "catalog:",
"dayjs": "catalog:", "dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1", "docusaurus-plugin-image-zoom": "^3.0.1",
"leader-line-new": "^1.1.9", "leader-line-new": "^1.1.9",
"postcss": "^8.5.15", "postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5", "posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7", "random-word-slugs": "^0.1.7",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"swiper": "^12.2.0", "swiper": "^12.2.0",
"tailwindcss": "^4.3.1" "tailwindcss": "^4.3.2"
}, },
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.1", "@docusaurus/module-type-aliases": "^3.10.1",
+2 -1
View File
@@ -59,10 +59,11 @@
"@modelcontextprotocol/sdk": "catalog:", "@modelcontextprotocol/sdk": "catalog:",
"@scalar/api-reference-react": "catalog:", "@scalar/api-reference-react": "catalog:",
"@tabler/icons-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": "catalog:",
"@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:", "@tanstack/react-query-next-experimental": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:", "@trpc/client": "catalog:",
"@trpc/next": "catalog:", "@trpc/next": "catalog:",
"@trpc/react-query": "catalog:", "@trpc/react-query": "catalog:",
@@ -1,49 +1,32 @@
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core"; import superjson from "superjson";
import { parse, stringify } from "superjson";
import { fetchApi } from "@homarr/api/client"; import { fetchApi } from "@homarr/api/client";
import { import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
getActiveQueryCacheBoardId,
isPersistableWidgetQueryKey,
queryCacheStoragePrefix,
} from "@homarr/api/query-cache";
const queryCacheStorage = { const queryCacheStorage = {
async getItem(_key) { getItem: (_key: string) => null as string | null,
return null; async setItem(_key: string, value: string) {
},
async setItem(key, value) {
const boardId = getActiveQueryCacheBoardId(); const boardId = getActiveQueryCacheBoardId();
if (!boardId) return; if (!boardId) return;
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
try { console.warn("[query-cache] persist failed", error);
await fetchApi.queryCache.setItem.mutate({ boardId, key, value }); });
} catch {
// Query persistence is best-effort; failed writes should not affect widget rendering.
}
}, },
async removeItem(key) { async removeItem(_key: string) {
const boardId = getActiveQueryCacheBoardId(); const boardId = getActiveQueryCacheBoardId();
if (!boardId) return; if (!boardId) return;
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
try { console.warn("[query-cache] remove failed", error);
await fetchApi.queryCache.removeItem.mutate({ boardId, key }); });
} catch {
// Query persistence is best-effort; failed removals should not affect widget rendering.
}
}, },
} satisfies AsyncStorage<string>; };
export const createWidgetQueryPersister = () => export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({ createAsyncStoragePersister({
storage: typeof window === "undefined" ? undefined : queryCacheStorage, storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY, key: queryCacheStoragePrefix,
prefix: queryCacheStoragePrefix, throttleTime: 2000,
refetchOnRestore: true, serialize: (data) => superjson.stringify(data),
serialize: (query) => stringify(query), deserialize: (data) => superjson.parse(data),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
}); });
@@ -2,9 +2,10 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } 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 { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { import {
createWSClient, createWSClient,
httpBatchStreamLink, httpBatchStreamLink,
@@ -21,7 +22,13 @@ import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache"; import {
isPersistableWidgetQueryKey,
queryCacheBuster,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
} from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications"; import { showWarningNotification } from "@homarr/notifications";
@@ -29,7 +36,6 @@ import { showWarningNotification } from "@homarr/notifications";
import { createWidgetQueryPersister } from "./query-cache-persister"; import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
// window is not defined on server side
if (typeof window === "undefined") { if (typeof window === "undefined") {
return "ws"; return "ws";
} }
@@ -55,32 +61,36 @@ const wsClient = createWSClient({
}); });
export function TRPCReactProvider(props: PropsWithChildren) { export function TRPCReactProvider(props: PropsWithChildren) {
const [queryPersister] = useState(() => createWidgetQueryPersister()); const [persister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState( const [queryClient] = useState(() => {
() => const client = new QueryClient({
new QueryClient({ defaultOptions: {
defaultOptions: { queries: {
queries: { staleTime: queryCacheDefaultStaleTimeMs,
persister: queryPersister.persisterFn, gcTime: queryCacheDefaultGcTimeMs,
staleTime: queryCacheDefaultStaleTimeMs, refetchOnWindowFocus: false,
}, refetchOnReconnect: false,
mutations: { retry: 3,
onError(error) { },
if ( mutations: {
error instanceof TRPCClientError && onError(error) {
error.data?.code === "FORBIDDEN" && if (
error.message === "Mutations are disabled in demo mode" error instanceof TRPCClientError &&
) { error.data?.code === "FORBIDDEN" &&
showWarningNotification({ error.message === "Mutations are disabled in demo mode"
title: "Demo mode", ) {
message: "This action is 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(() => { const [trpcClient] = useState(() => {
return clientApi.createClient({ return clientApi.createClient({
@@ -98,9 +108,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: splitLink({ false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input), condition: ({ input }) => isNonJsonSerializable(input),
true: httpLink({ true: httpLink({
/**
* We don't want to transform the data here as we want to use form data
*/
transformer: { transformer: {
serialize(object: unknown) { serialize(object: unknown) {
return object; return object;
@@ -115,7 +122,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: httpBatchStreamLink({ false: httpBatchStreamLink({
transformer: superjson, transformer: superjson,
url: getTrpcUrl(), 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)"), headers: createHeadersCallbackForSource("nextjs-react (json)"),
}), }),
}), }),
@@ -126,10 +133,21 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
buster: queryCacheBuster,
maxAge: queryCacheDefaultGcTimeMs,
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
},
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration> <ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </PersistQueryClientProvider>
</clientApi.Provider> </clientApi.Provider>
); );
} }
@@ -5,10 +5,10 @@ import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content // Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss"; import "~/styles/gridstack.scss";
import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } 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 superjson from "superjson";
import { queryCacheBuster } from "@homarr/api/query-cache";
import { getQueryClient } from "@homarr/api/server"; import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client"; import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
@@ -17,7 +17,7 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { getAllQueryCacheAsync } from "@homarr/redis"; import { getQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -118,36 +118,18 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}; };
}; };
interface PersistedEntry {
state: { data: unknown; dataUpdatedAt: number; status: string };
queryKey: unknown[];
queryHash: string;
}
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) { async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try { try {
const entries = await getAllQueryCacheAsync(userId, boardId); const serialized = await getQueryCacheAsync(userId, boardId);
const queries: DehydratedState["queries"] = []; if (!serialized) return null;
for (const serialized of Object.values(entries)) { const persisted = superjson.parse<PersistedClient>(serialized);
try { if (persisted.buster !== queryCacheBuster) return null;
const persisted = parse<PersistedEntry>(serialized); if (!persisted?.clientState?.queries?.length) return null;
if (persisted.state.status !== "success") continue;
queries.push({ return <HydrationBoundary state={persisted.clientState} />;
state: persisted.state as DehydratedState["queries"][number]["state"], } catch (error) {
queryKey: persisted.queryKey, logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
queryHash: persisted.queryHash,
});
} catch {
// skip malformed entries
}
}
if (queries.length === 0) return null;
return <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return null; return null;
} }
} }
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core"; import type { MantineColor } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } 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) { export function DockerTable({ initialData }: DockerTableProps) {
const t = useI18n(); const t = useI18n();
const tDocker = useScopedI18n("docker"); const tDocker = useScopedI18n("docker");
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, { const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
initialData, initialData,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}); });
const relativeTime = useTimeAgo(data?.timestamp ?? new Date()); const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const utils = clientApi.useUtils(); const mutate = useCallback(() => {
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ void refetch().then((result) => {
async onSuccess() { if (result.isError) {
await utils.docker.getContainers.invalidate(); showErrorNotification({
showSuccessNotification({ title: tDocker("action.refresh.notification.error.title"),
title: tDocker("action.refresh.notification.success.title"), message: tDocker("action.refresh.notification.error.message"),
message: tDocker("action.refresh.notification.success.message"), });
}); } else {
}, showSuccessNotification({
onError() { title: tDocker("action.refresh.notification.success.title"),
showErrorNotification({ message: tDocker("action.refresh.notification.success.message"),
title: tDocker("action.refresh.notification.error.title"), });
message: tDocker("action.refresh.notification.error.message"), }
}); });
}, }, [refetch, tDocker]);
});
const containers = data?.containers ?? []; const containers = data?.containers ?? [];
@@ -207,7 +205,12 @@ export function DockerTable({ initialData }: DockerTableProps) {
mantineTableHeadCellProps: { style: { padding: "4px 8px" } }, mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />, renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
renderTopToolbarCustomActions: () => ( renderTopToolbarCustomActions: () => (
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}> <Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isFetching}
>
{tDocker("action.refresh.label")} {tDocker("action.refresh.label")}
</Button> </Button>
), ),
@@ -2,7 +2,7 @@ import type { MutableRefObject } from "react";
import { useRef } from "react"; import { useRef } from "react";
import { Badge, Card } from "@mantine/core"; import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
@@ -105,12 +105,16 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
updateItemOptions({ itemId: item.id, newOptions }); updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations = const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
const queryClient = useQueryClient();
return ( return (
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
onReset={reset} onReset={() => {
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
reset();
}}
fallbackRender={({ resetErrorBoundary, error }) => ( fallbackRender={({ resetErrorBoundary, error }) => (
<> <>
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} /> <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
@@ -1,9 +1,11 @@
"use client"; "use client";
import type { MutableRefObject, ReactNode } from "react"; import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Menu, Switch } from "@mantine/core"; import { Group, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react"; 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 { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/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 { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs; 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( const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>, () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
@@ -214,7 +222,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
)} )}
<> <>
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />} {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
<Group justify="space-between" wrap="nowrap">
{tMenu("refresh")}
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
</Text>
</Group>
</Menu.Item>
<Menu.Item <Menu.Item
closeMenuOnClick closeMenuOnClick
leftSection={<IconSettings size={16} />} leftSection={<IconSettings size={16} />}
@@ -248,3 +264,24 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
</Menu> </Menu>
); );
}; };
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`;
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { invalidateUpdateCheckerCacheAsync } from "./invalidate-update-checker-cache";
import { cleanupSessionsAsync } from "./session-cleanup"; import { cleanupSessionsAsync } from "./session-cleanup";
import { warmUpdateCheckerAsync } from "./warm-update-checker";
export async function onStartAsync() { export async function onStartAsync() {
await cleanupSessionsAsync(); await cleanupSessionsAsync();
await invalidateUpdateCheckerCacheAsync(); await warmUpdateCheckerAsync();
} }
@@ -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 }));
}
}
@@ -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 }));
}
}
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = "24.18.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.68.1", "version": "1.69.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -36,8 +36,6 @@ const logger = createLogger({ module: "analytics" });
type AnalyticsResult = "sent" | "disabled" | "failed"; 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 ( const getOrCreateInstanceId = async (
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>, analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
): Promise<string> => { ): Promise<string> => {
+4 -2
View File
@@ -1,9 +1,11 @@
import type { QueryKey } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24; export const queryCacheDefaultStaleTimeMs = 0;
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5; export const queryCacheDefaultRefetchIntervalMs = 30_000;
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
export const queryCacheMaxValueBytes = 1024 * 1024; export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query"; export const queryCacheStoragePrefix = "homarr-widget-query";
export const queryCacheBuster = "v2-superjson";
let activeQueryCacheBoardId: string | null = null; let activeQueryCacheBoardId: string | null = null;
@@ -25,9 +25,7 @@ export const dockerRouter = createTRPCRouter({
.concat(dockerMiddleware()) .concat(dockerMiddleware())
.query(async () => { .query(async () => {
const innerHandler = dockerContainersRequestHandler.handler({}); const innerHandler = dockerContainersRequestHandler.handler({});
const result = await innerHandler.getCachedOrUpdatedDataAsync({ const result = await innerHandler.getDataAsync();
forceUpdate: false,
});
const { data, timestamp } = result; const { data, timestamp } = result;
@@ -36,26 +34,6 @@ export const dockerRouter = createTRPCRouter({
timestamp, timestamp,
}; };
}), }),
subscribeContainers: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.subscription(() => {
return observable<DockerContainer[]>((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 startAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.meta({ .meta({
@@ -73,9 +51,6 @@ export const dockerRouter = createTRPCRouter({
await container.start(); await container.start();
}), }),
); );
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}), }),
stopAll: permissionRequiredProcedure stopAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
@@ -94,9 +69,6 @@ export const dockerRouter = createTRPCRouter({
await container.stop(); await container.stop();
}), }),
); );
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}), }),
restartAll: permissionRequiredProcedure restartAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
@@ -116,9 +88,6 @@ export const dockerRouter = createTRPCRouter({
await container.restart(); await container.restart();
}), }),
); );
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}), }),
removeAll: permissionRequiredProcedure removeAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
@@ -138,9 +107,6 @@ export const dockerRouter = createTRPCRouter({
await container.remove(); await container.remove();
}), }),
); );
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}), }),
logs: permissionRequiredProcedure logs: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
-2
View File
@@ -30,8 +30,6 @@ export const iconsRouter = createTRPCRouter({
columns: { id: true, name: true, url: true }, columns: { id: true, name: true, url: true },
where: whereCondition, where: whereCondition,
orderBy: (table, { asc, sql }) => { 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 svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name); const nameAsc = asc(table.name);
if (term.length === 0) { if (term.length === 0) {
+17 -29
View File
@@ -2,53 +2,41 @@ import { z } from "zod/v4";
import { eq } from "@homarr/db"; import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema"; 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 { createTRPCRouter, internalProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access"; import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheKeyInput = z.object({ const queryCacheBoardInput = z.object({
boardId: z.string().min(1), 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), value: z.string().max(queryCacheMaxValueBytes * 2),
}); });
const createChannel = (input: z.infer<typeof queryCacheKeyInput>, userId: string) =>
createQueryCacheChannel({
userId,
boardId: input.boardId,
key: input.key,
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => { const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view"); await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
}; };
export const queryCacheRouter = createTRPCRouter({ export const queryCacheRouter = createTRPCRouter({
getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => { setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId); await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous";
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");
return { 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: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId); await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous";
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous"); await removeQueryCacheAsync(userId, input.boardId);
await channel.removeAsync();
}), }),
}); });
@@ -23,28 +23,12 @@ vi.mock("@homarr/request-handler/docker", () => ({
}, },
dockerContainersRequestHandler: { dockerContainersRequestHandler: {
handler: () => ({ handler: () => ({
getCachedOrUpdatedDataAsync: async () => { getDataAsync: async () => {
return await Promise.resolve({ containers: [] }); return await Promise.resolve({ data: [], timestamp: new Date() });
},
invalidateAsync: async () => {
return await Promise.resolve();
}, },
}), }),
}, },
})); }));
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", () => ({ vi.mock("@homarr/docker/env", () => ({
env: { env: {
ENABLE_DOCKER: true, ENABLE_DOCKER: true,
@@ -67,12 +51,10 @@ const validInputs: {
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key]; [key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
} = { } = {
getContainers: undefined, getContainers: undefined,
subscribeContainers: undefined,
startAll: { ids: ["1"] }, startAll: { ids: ["1"] },
stopAll: { ids: ["1"] }, stopAll: { ids: ["1"] },
restartAll: { ids: ["1"] }, restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] }, removeAll: { ids: ["1"] },
invalidate: undefined,
logs: { id: "1", tail: 200 }, logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 }, subscribeLogs: { id: "1", tail: 200 },
}; };
+1 -1
View File
@@ -9,7 +9,7 @@ export const updateCheckerRouter = createTRPCRouter({
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => { getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
try { try {
const handler = updateCheckerRequestHandler.handler({}); const handler = updateCheckerRequestHandler.handler({});
const data = await handler.getCachedOrUpdatedDataAsync({}); const data = await handler.getDataAsync();
return data.data.availableUpdates; return data.data.availableUpdates;
} catch (error) { } catch (error) {
logger.error(new Error("Failed to get available updates", { cause: error })); logger.error(new Error("Failed to get available updates", { cause: error }));
@@ -45,9 +45,7 @@ export const anchorNotesRouter = createTRPCRouter({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 }); const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
const { data } = await handler.getCachedOrUpdatedDataAsync({ const { data } = await handler.getDataAsync();
forceUpdate: false,
});
return data; return data;
}), }),
@@ -57,9 +55,7 @@ export const anchorNotesRouter = createTRPCRouter({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId }); const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getCachedOrUpdatedDataAsync({ const { data } = await handler.getDataAsync();
forceUpdate: false,
});
return data; return data;
}), }),
@@ -79,9 +75,6 @@ export const anchorNotesRouter = createTRPCRouter({
...(normalizedContent !== undefined ? { content: normalizedContent } : {}), ...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
}); });
const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
await noteHandler.invalidateAsync();
return updatedNote; return updatedNote;
} catch (error) { } catch (error) {
if (error instanceof ResponseError && error.statusCode === 400) { if (error instanceof ResponseError && error.statusCode === 400) {
-53
View File
@@ -1,10 +1,8 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { sendPingRequestAsync } from "@homarr/ping"; import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
import { AppRepository } from "../app"; import { AppRepository } from "../app";
@@ -45,55 +43,4 @@ export const appRouter = createTRPCRouter({
...pingResult, ...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);
};
},
);
}),
}); });
@@ -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 { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -11,29 +8,11 @@ export const archiveTeamWarriorRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior")) .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {}); const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data, timestamp } = await handler.getDataAsync();
return { return {
status: data, status: data,
updatedAt: timestamp, 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();
};
});
}),
}); });
@@ -13,14 +13,14 @@ export const audioStatsRouter = createTRPCRouter({
const fetchByKind = { const fetchByKind = {
navidrome: async () => { navidrome: async () => {
const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {}); const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {});
return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; return (await handler.getDataAsync()).data;
}, },
audiobookshelf: async () => { audiobookshelf: async () => {
const handler = audiobookshelfRequestHandler.handler( const handler = audiobookshelfRequestHandler.handler(
{ ...ctx.integration, kind: "audiobookshelf" as const }, { ...ctx.integration, kind: "audiobookshelf" as const },
{}, {},
); );
return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; return (await handler.getDataAsync()).data;
}, },
} as const; } as const;
+11 -127
View File
@@ -1,16 +1,12 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { import {
beszelAlertsRequestHandler, beszelAlertsRequestHandler,
beszelStatsRequestHandler, beszelStatsRequestHandler,
beszelSystemsRequestHandler, beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel"; } from "@homarr/request-handler/beszel";
import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -33,9 +29,7 @@ export const beszelRouter = createTRPCRouter({
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ const { data, timestamp } = await innerHandler.getDataAsync();
forceUpdate: false,
});
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
@@ -71,30 +65,6 @@ export const beszelRouter = createTRPCRouter({
return results; 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 getAlerts: publicProcedure
.meta({ .meta({
mcp: { mcp: {
@@ -125,13 +95,17 @@ export const beszelRouter = createTRPCRouter({
maxHistoryItems: input.maxHistoryItems, maxHistoryItems: input.maxHistoryItems,
}); });
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {}); const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
const [alertsResult, systemsResult] = await Promise.all([ const [alertsSettled, systemsSettled] = await Promise.allSettled([
alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), alertsHandler.getDataAsync(),
systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), systemsHandler.getDataAsync(),
]); ]);
if (alertsSettled.status === "rejected") throw alertsSettled.reason;
const alertsResult = alertsSettled.value;
const systemNameMap: Record<string, string> = {}; const systemNameMap: Record<string, string> = {};
for (const system of systemsResult.data) { if (systemsSettled.status === "fulfilled") {
systemNameMap[system.id] = system.name; for (const system of systemsSettled.value.data) {
systemNameMap[system.id] = system.name;
}
} }
return { return {
integrationId: integration.id, integrationId: integration.id,
@@ -170,39 +144,6 @@ export const beszelRouter = createTRPCRouter({
return results; 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 getSystemStats: publicProcedure
.meta({ .meta({
mcp: { mcp: {
@@ -237,7 +178,7 @@ export const beszelRouter = createTRPCRouter({
timePeriod: input.timePeriod, timePeriod: input.timePeriod,
includeDocker: input.includeDocker, includeDocker: input.includeDocker,
}); });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data, timestamp } = await innerHandler.getDataAsync();
logger.debug("getSystemStats completed", { logger.debug("getSystemStats completed", {
userId: ctx.session?.user?.id, userId: ctx.session?.user?.id,
integrationId: integration.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,
});
};
});
}),
}); });
+1 -43
View File
@@ -1,13 +1,8 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { Modify } from "@homarr/common/types";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; 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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { CalendarEvent } from "@homarr/integrations/types";
import { radarrReleaseTypes } from "@homarr/integrations/types"; import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
@@ -39,9 +34,7 @@ export const calendarRouter = createTRPCRouter({
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input; const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ const { data } = await innerHandler.getDataAsync();
forceUpdate: false,
});
return { return {
events: data, events: data,
@@ -72,39 +65,4 @@ export const calendarRouter = createTRPCRouter({
return []; 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<Integration, { kind: IntegrationKindByCategory<"calendar"> }>;
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();
});
};
});
}),
}); });
+10 -36
View File
@@ -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 { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const coolifyRouter = createTRPCRouter({ export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify")) .concat(createManyIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = coolifyRequestHandler.handler(integration, {});
const innerHandler = coolifyRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
instanceInfo: data, instanceInfo: data,
updatedAt: timestamp, 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());
}; };
}); });
}), }),
+6 -61
View File
@@ -1,15 +1,11 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; 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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({ export const dnsHoleRouter = createTRPCRouter({
@@ -23,50 +19,11 @@ export const dnsHoleRouter = createTRPCRouter({
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
const innerHandler = dnsHoleRequestHandler.handler(integration, {}); return {
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
forceUpdate: false, summary: data,
});
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<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
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();
});
}; };
}); });
}), }),
@@ -83,12 +40,6 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration } }) => { .mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
await client.enableAsync(); await client.enableAsync();
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
// We need to wait for the integration to be enabled before invalidating the cache
await new Promise<void>((resolve) => {
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
});
}), }),
disable: protectedProcedure disable: protectedProcedure
@@ -108,11 +59,5 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration); await client.disableAsync(input.duration);
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
// We need to wait for the integration to be disabled before invalidating the cache
await new Promise<void>((resolve) => {
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
});
}), }),
}); });
+7 -51
View File
@@ -1,16 +1,12 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; 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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations"; import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration"; import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) => const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
@@ -28,52 +24,12 @@ export const downloadsRouter = createTRPCRouter({
.concat(createDownloadClientIntegrationMiddleware("query")) .concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) })) .input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); const { data, timestamp } = await innerHandler.getDataAsync();
return {
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
forceUpdate: false, data,
});
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<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
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();
});
}; };
}); });
}), }),
+24 -202
View File
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "@homarr/integrations";
import { import {
firewallCpuRequestHandler, firewallCpuRequestHandler,
firewallInterfacesRequestHandler, firewallInterfacesRequestHandler,
@@ -18,198 +7,31 @@ import {
} from "@homarr/request-handler/firewall"; } from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; 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<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
};
}),
);
export const firewallRouter = createTRPCRouter({ export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: publicProcedure getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
.query(async ({ ctx }) => { getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
const results = await Promise.all( getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
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<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
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<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
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<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
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<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
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();
});
};
});
}),
}); });
@@ -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 { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({ export const healthMonitoringRouter = createTRPCRouter({
@@ -20,48 +17,13 @@ export const healthMonitoringRouter = createTRPCRouter({
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"), createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
) )
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
const innerHandler = systemInfoRequestHandler.handler(integration, {}); return {
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ integrationId: integration.id,
forceUpdate: false, integrationName: integration.name,
}); healthInfo: data,
updatedAt: timestamp,
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();
});
}; };
}); });
}), }),
@@ -76,24 +38,7 @@ export const healthMonitoringRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock")) .concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ const { data } = await innerHandler.getDataAsync();
forceUpdate: false,
});
return data; return data;
}), }),
subscribeClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.subscription(({ ctx }) => {
return observable<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = [];
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next(healthInfo);
});
unsubscribes.push(unsubscribe);
return () => {
unsubscribe();
};
});
}),
}); });
+3 -3
View File
@@ -17,7 +17,7 @@ const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
export const immichRouter = createTRPCRouter({ export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {}); const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const data = await innerHandler.getDataAsync();
return data.data; return data.data;
}), }),
@@ -30,13 +30,13 @@ export const immichRouter = createTRPCRouter({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId }); const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const data = await innerHandler.getDataAsync();
return data.data; return data.data;
}), }),
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {}); const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const data = await innerHandler.getDataAsync();
return data.data; return data.data;
}), }),
}); });
@@ -1,13 +1,12 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager"; import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration"; import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -17,39 +16,13 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query")) .concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const innerHandler = indexerManagerRequestHandler.handler(integration, {}); const { data: indexers } = await innerHandler.getDataAsync();
const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
indexers, 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();
});
}; };
}); });
}), }),
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaReleaseRouter = createTRPCRouter({ export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integration: { integration: {
id: integration.id, id: integration.id,
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
updatedAt: timestamp, updatedAt: timestamp,
}, },
releases: data, releases: data,
}; };
}), });
);
return results.flatMap((result) => return results.flatMap((result) =>
result.releases.map((release) => ({ result.releases.map((release) => ({
...release, ...release,
@@ -37,31 +30,4 @@ export const mediaReleaseRouter = createTRPCRouter({
})), })),
); );
}), }),
subscribeToReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"mediaRelease"> }>;
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();
});
};
});
}),
}); });
@@ -1,14 +1,13 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import type { MediaRequest } from "@homarr/integrations/types";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const mediaRequestsRouter = createTRPCRouter({ export const mediaRequestsRouter = createTRPCRouter({
@@ -22,22 +21,10 @@ export const mediaRequestsRouter = createTRPCRouter({
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
const innerHandler = mediaRequestListRequestHandler.handler(integration, {}); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ });
forceUpdate: false,
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
data,
};
}),
);
return results return results
.flatMap(({ data, integration }) => .flatMap(({ data, integration }) =>
data.map((request) => ({ 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 getStats: publicProcedure
.meta({ .meta({
mcp: { mcp: {
@@ -92,22 +53,10 @@ export const mediaRequestsRouter = createTRPCRouter({
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {}); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ });
forceUpdate: false,
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
data,
};
}),
);
return { return {
stats: results.flatMap((result) => result.data.stats), stats: results.flatMap((result) => result.data.stats),
users: results users: results
@@ -139,7 +88,6 @@ export const mediaRequestsRouter = createTRPCRouter({
) )
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
const answerActions = { const answerActions = {
approve: (id: number) => integrationInstance.approveRequestAsync(id), approve: (id: number) => integrationInstance.approveRequestAsync(id),
@@ -147,6 +95,5 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const; } as const;
await answerActions[input.answer](input.requestId); await answerActions[input.answer](input.requestId);
await innerHandler.invalidateAsync();
}), }),
}); });
@@ -1,12 +1,11 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"; import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration"; import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -24,46 +23,11 @@ export const mediaServerRouter = createTRPCRouter({
.concat(createMediaServerIntegrationMiddleware("query")) .concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() })) .input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const { data } = await mediaServerRequestHandler
const innerHandler = mediaServerRequestHandler.handler(integration, { .handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
showOnlyPlaying: input.showOnlyPlaying, .getDataAsync();
}); return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
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();
});
};
}); });
}), }),
}); });
@@ -1,7 +1,4 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { MediaTranscoding } from "@homarr/request-handler/media-transcoding";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding"; import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { paginatedSchema } from "@homarr/validation/common"; import { paginatedSchema } from "@homarr/validation/common";
@@ -21,26 +18,11 @@ export const mediaTranscodingRouter = createTRPCRouter({
pageOffset: (input.page - 1) * input.pageSize, pageOffset: (input.page - 1) * input.pageSize,
pageSize: input.pageSize, pageSize: input.pageSize,
}); });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data } = await innerHandler.getDataAsync();
return { return {
integrationId: ctx.integration.id, integrationId: ctx.integration.id,
data, 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;
});
}),
}); });
+1 -18
View File
@@ -1,7 +1,5 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; 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 { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -16,21 +14,6 @@ export const minecraftRouter = createTRPCRouter({
isBedrockServer: input.isBedrockServer, isBedrockServer: input.isBedrockServer,
domain: input.domain, domain: input.domain,
}); });
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); return await innerHandler.getDataAsync();
}),
subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
return observable<MinecraftServerStatus>((emit) => {
const innerHandler = minecraftServerStatusRequestHandler.handler({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next(data);
});
return () => {
unsubscribe();
};
});
}), }),
}); });
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller"; import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const networkControllerRouter = createTRPCRouter({ export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = networkControllerRequestHandler.handler(integration, {});
const innerHandler = networkControllerRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integration: { integration: {
id: integration.id, id: integration.id,
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
}, },
summary: data, summary: data,
updatedAt: timestamp, updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeToSummary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"networkController"> }>;
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();
});
}; };
}); });
}), }),
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { Notification } from "@homarr/integrations";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications"; import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration"; import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) => const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
@@ -18,46 +13,18 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query")) .unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = notificationsRequestHandler.handler(integration, {});
const innerHandler = notificationsRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integration: { integration: {
id: integration.id, id: integration.id,
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
updatedAt: timestamp, updatedAt: timestamp,
}, },
data, data,
};
}),
);
}),
subscribeNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"notifications"> }>;
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();
});
}; };
}); });
}), }),
@@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({ export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => { getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {}); const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const data = await innerHandler.getDataAsync();
return data.data; return data.data;
}), }),
}); });
+1 -3
View File
@@ -89,9 +89,7 @@ export const releasesRouter = createTRPCRouter({
providerUrl: repository.providerUrl, providerUrl: repository.providerUrl,
token: useToken ? tokensByProvider.get(repository.provider) : undefined, token: useToken ? tokensByProvider.get(repository.provider) : undefined,
}) })
.getCachedOrUpdatedDataAsync({ .getDataAsync();
forceUpdate: false,
});
return { return {
id: repositoryId, id: repositoryId,
+14 -26
View File
@@ -1,4 +1,3 @@
import { observable } from "@trpc/server/observable";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -6,12 +5,14 @@ import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { eq } from "@homarr/db"; import { eq } from "@homarr/db";
import { items, users } from "@homarr/db/schema"; 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 { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import type { WidgetComponentProps } from "../../../../widgets/src"; import type { WidgetComponentProps } from "../../../../widgets/src";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "rssFeed" });
const feedsInput = z.object({ const feedsInput = z.object({
urls: z.array(z.string()).max(100), urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(), maximumAmountPosts: z.number(),
@@ -23,41 +24,28 @@ export const rssFeedRouter = createTRPCRouter({
? input.urls ? input.urls
: await restrictUrlsAsync(ctx.db, input.urls); : await restrictUrlsAsync(ctx.db, input.urls);
const rssFeeds = await Promise.all( const settled = await Promise.allSettled(
urls.map(async (url) => { urls.map(async (url) => {
const innerHandler = rssFeedsRequestHandler.handler({ const innerHandler = rssFeedsRequestHandler.handler({
url, url,
count: input.maximumAmountPosts, count: input.maximumAmountPosts,
}); });
return await innerHandler.getCachedOrUpdatedDataAsync({ return await innerHandler.getDataAsync();
forceUpdate: false,
});
}), }),
); );
return rssFeeds return settled
.flatMap((rssFeed) => rssFeed.data.entries) .flatMap((result, index) => {
.slice(0, input.maximumAmountPosts) if (result.status === "fulfilled") return result.value.data.entries;
.sort((entryA, entryB) => { logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
return [];
})
.toSorted((entryA, entryB) => {
return entryA.published && entryB.published return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0; : 0;
}); })
}), .slice(0, input.maximumAmountPosts);
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());
};
});
}), }),
}); });
+1 -27
View File
@@ -1,4 +1,3 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
@@ -25,31 +24,9 @@ export const smartHomeRouter = createTRPCRouter({
.concat(createSmartHomeIntegrationMiddleware("query")) .concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => { .query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ const { data } = await innerHandler.getDataAsync();
forceUpdate: false,
});
return data; 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 switchEntity: protectedProcedure
.meta({ .meta({
mcp: { mcp: {
@@ -64,9 +41,6 @@ export const smartHomeRouter = createTRPCRouter({
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId); const success = await client.triggerToggleAsync(input.entityId);
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
await innerHandler.invalidateAsync();
return success; return success;
}), }),
executeAutomation: protectedProcedure executeAutomation: protectedProcedure
@@ -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 { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({ export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker")) .concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
dashboard: data, dashboard: data,
updatedAt: timestamp, 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());
}; };
}); });
}), }),
+1 -1
View File
@@ -18,6 +18,6 @@ export const stockPriceRouter = createTRPCRouter({
timeRange: input.timeRange, timeRange: input.timeRange,
timeInterval: input.timeInterval, timeInterval: input.timeInterval,
}); });
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return await innerHandler.getDataAsync();
}), }),
}); });
+2 -15
View File
@@ -19,14 +19,7 @@ export const timetableRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const innerHandler = timetableGetTimetableRequestHandler.handler({ const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync();
baseUrl: input.baseUrl,
stationId: input.stationId,
limit: input.limit,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data; return data;
}), }),
searchStations: publicProcedure searchStations: publicProcedure
@@ -37,13 +30,7 @@ export const timetableRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const innerHandler = timetableSearchStationsRequestHandler.handler({ const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
baseUrl: input.baseUrl,
query: input.query,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data; return data;
}), }),
}); });
+12 -38
View File
@@ -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 { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({ export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => { getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = tracearrRequestHandler.handler(integration, {});
const innerHandler = tracearrRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
dashboard: data, dashboard: data,
updatedAt: timestamp, updatedAt: timestamp,
}; };
}), });
);
return results;
}), }),
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());
};
});
}),
}); });
+22 -43
View File
@@ -1,4 +1,3 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,6 +13,7 @@ import {
} from "@homarr/request-handler/umami"; } from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" }); const logger = createLogger({ module: "umami-router" });
@@ -39,26 +39,22 @@ export const umamiRouter = createTRPCRouter({
) )
.concat(createManyIntegrationMiddleware("query", "umami")) .concat(createManyIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = umamiRequestHandler.handler(integration, {
const innerHandler = umamiRequestHandler.handler(integration, { websiteId: input.websiteId,
websiteId: input.websiteId, timeFrame: input.timeFrame,
timeFrame: input.timeFrame, eventName: input.eventName,
eventName: input.eventName, });
}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
visitorStats: data, visitorStats: data,
updatedAt: timestamp, updatedAt: timestamp,
}; };
}), });
);
return results;
}), }),
getEventNames: publicProcedure getEventNames: publicProcedure
@@ -67,7 +63,7 @@ export const umamiRouter = createTRPCRouter({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
try { try {
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId }); const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data } = await innerHandler.getDataAsync();
return data; return data;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@@ -93,7 +89,7 @@ export const umamiRouter = createTRPCRouter({
timeFrame: input.timeFrame, timeFrame: input.timeFrame,
limit: input.limit, limit: input.limit,
}); });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data } = await innerHandler.getDataAsync();
return data; return data;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@@ -123,7 +119,7 @@ export const umamiRouter = createTRPCRouter({
timeFrame: input.timeFrame, timeFrame: input.timeFrame,
limit: input.limit, limit: input.limit,
}); });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data } = await innerHandler.getDataAsync();
return data; return data;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@@ -148,13 +144,13 @@ export const umamiRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "umami")) .concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
try { try {
const sortedNames = [...input.eventNames].sort(); const sortedNames = [...input.eventNames].toSorted();
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, { const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId, websiteId: input.websiteId,
timeFrame: input.timeFrame, timeFrame: input.timeFrame,
eventNames: sortedNames, eventNames: sortedNames,
}); });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data } = await innerHandler.getDataAsync();
return data; return data;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@@ -176,7 +172,7 @@ export const umamiRouter = createTRPCRouter({
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, { const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId, websiteId: input.websiteId,
}); });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data } = await innerHandler.getDataAsync();
return data; return data;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@@ -185,21 +181,4 @@ export const umamiRouter = createTRPCRouter({
return 0; return 0;
} }
}), }),
subscribeActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.subscription(({ ctx, input }) => {
return observable<number>((emit) => {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const unsubscribe = innerHandler.subscribe((count) => {
emit.next(count);
});
return () => {
unsubscribe();
};
});
}),
}); });
+10 -36
View File
@@ -1,50 +1,24 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { UpsSummary } from "@homarr/integrations/types";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups"; import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({ export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const innerHandler = upsSummariesRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
summaries: data, summaries: data,
updatedAt: timestamp, 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());
}; };
}); });
}), }),
+10 -41
View File
@@ -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 { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({ export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma")) .concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync();
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
dashboard: data, dashboard: data,
updatedAt: timestamp, 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());
}; };
}); });
}), }),
+8 -42
View File
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const createVpnIntegrationMiddleware = () =>
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
export const vpnRouter = createTRPCRouter({ export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => { getSummaries: publicProcedure
return await Promise.all( .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
ctx.integrations.map(async (integration) => { .query(async ({ ctx }) => {
const innerHandler = vpnSummaryHandler.handler(integration, {}); return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return { return {
integration: { integration: {
id: integration.id, id: integration.id,
@@ -29,31 +20,6 @@ export const vpnRouter = createTRPCRouter({
}, },
summary: data, summary: data,
}; };
}), });
); }),
}),
subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"vpn"> }>;
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();
});
};
});
}),
}); });
+1 -13
View File
@@ -1,7 +1,5 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { Weather } from "@homarr/request-handler/weather";
import { weatherRequestHandler } from "@homarr/request-handler/weather"; import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -14,16 +12,6 @@ const atLocationInput = z.object({
export const weatherRouter = createTRPCRouter({ export const weatherRouter = createTRPCRouter({
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => { atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
const handler = weatherRequestHandler.handler(input); const handler = weatherRequestHandler.handler(input);
return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data); return await handler.getDataAsync().then((result) => result.data);
}),
subscribeAtLocation: publicProcedure.input(atLocationInput).subscription(({ input }) => {
return observable<Weather>((emit) => {
const handler = weatherRequestHandler.handler(input);
const unsubscribe = handler.subscribe((data) => {
emit.next(data);
});
return unsubscribe;
});
}), }),
}); });
@@ -39,7 +39,7 @@ export const searchDuckDuckGoBangsAsync = async (input: {
const queryTokenPrefix = normalizeBangToken(input.query); const queryTokenPrefix = normalizeBangToken(input.query);
if (!queryTokenPrefix) return []; if (!queryTokenPrefix) return [];
const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({}); const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getDataAsync();
const startIndex = lowerBound(allBangs, queryTokenPrefix); const startIndex = lowerBound(allBangs, queryTokenPrefix);
const matches: DuckDuckGoBang[] = []; const matches: DuckDuckGoBang[] = [];
+29
View File
@@ -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<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
): Promise<TResult[]> {
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 [];
});
}
+38 -86
View File
@@ -10,59 +10,31 @@ import {
isPersistableWidgetQueryKey, isPersistableWidgetQueryKey,
queryCacheDefaultStaleTimeMs, queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes, queryCacheMaxValueBytes,
queryCacheRetentionMs,
} from "@homarr/api/query-cache"; } from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache"; import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => { const redisMock = vi.hoisted(() => {
const values = new Map<string, { value: string; expiresAt: number }>(); const values = new Map<string, string>();
const calls: {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}[] = [];
return { return {
values, values,
calls, setQueryCacheAsync: vi.fn(
createQueryCacheChannel: vi.fn( async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => { if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
calls.push(options); values.set(`${_userId}:${_boardId}`, value);
const storageKey = `${options.userId}:${options.boardId}:${options.key}`; return true;
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);
},
};
}, },
), ),
removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
values.delete(`${_userId}:${_boardId}`);
}),
}; };
}); });
vi.mock("@homarr/redis", () => ({ vi.mock("@homarr/redis", () => ({
createQueryCacheChannel: redisMock.createQueryCacheChannel, setQueryCacheAsync: redisMock.setQueryCacheAsync,
removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
})); }));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
@@ -70,8 +42,8 @@ vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
redisMock.values.clear(); redisMock.values.clear();
redisMock.calls.length = 0; redisMock.setQueryCacheAsync.mockClear();
redisMock.createQueryCacheChannel.mockClear(); redisMock.removeQueryCacheAsync.mockClear();
}); });
const createSession = (userId = createId()): Session => ({ const createSession = (userId = createId()): Session => ({
@@ -104,7 +76,7 @@ describe("isPersistableWidgetQueryKey", () => {
}); });
describe("queryCacheRouter", () => { 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 db = createDb();
const session = createSession(); const session = createSession();
await insertUserAsync(db, session.user.id); await insertUserAsync(db, session.user.id);
@@ -112,14 +84,19 @@ describe("queryCacheRouter", () => {
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session }); 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, 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 caller.removeItem({ boardId: "board-1" });
expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
}); });
test("does not touch Redis when board access is denied", async () => { test("does not touch Redis when board access is denied", async () => {
@@ -127,32 +104,32 @@ describe("queryCacheRouter", () => {
await insertUserAsync(db, "owner"); await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false }); 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") }); 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"); await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled(); 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(); const db = createDb();
await insertUserAsync(db, "owner"); await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true }); await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null }); 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({ expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
userId: "anonymous", "anonymous",
boardId: "board-1", "board-1",
key: "query-key", "cached-value",
ttlMs: queryCacheRetentionMs, expect.any(Number),
maxValueBytes: queryCacheMaxValueBytes, queryCacheMaxValueBytes,
}); );
}); });
test("scopes signed-in cache entries by user and rejects oversized values", async () => { test("rejects oversized values", async () => {
const db = createDb(); const db = createDb();
const session = createSession("user-1"); const session = createSession("user-1");
await insertUserAsync(db, session.user.id); await insertUserAsync(db, session.user.id);
@@ -161,34 +138,9 @@ describe("queryCacheRouter", () => {
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session }); const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1); 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, 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();
}); });
}); });
+2
View File
@@ -116,6 +116,8 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) :
*/ */
export const publicProcedure = baseProcedure; export const publicProcedure = baseProcedure;
export const internalProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
+1 -10
View File
@@ -20,16 +20,7 @@ export const BoardProvider = ({
}: PropsWithChildren<{ }: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"]; initialBoard: RouterOutputs["board"]["getBoardByName"];
}>) => { }>) => {
const { data } = clientApi.board.getBoardByName.useQuery( const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard });
{ name: initialBoard.name },
{
initialData: initialBoard,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
setActiveQueryCacheBoardId(data.id);
// Update the board name so it can be used within updateBoard method // Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name); updateBoardName(initialBoard.name);
+6
View File
@@ -17,6 +17,12 @@ export function formatDuration(milliseconds: number) {
return `${minutes}:${String(seconds).padStart(2, "0")}`; 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 => { export const isDateWithin = (date: Date, relativeDate: string): boolean => {
if (relativeDate.length < 2) { if (relativeDate.length < 2) {
throw new Error("Relative date must be at least 2 characters long"); throw new Error("Relative date must be at least 2 characters long");
-52
View File
@@ -1,32 +1,6 @@
import { analyticsJob } from "./jobs/analytics"; import { analyticsJob } from "./jobs/analytics";
import { dockerContainersJob } from "./jobs/docker";
import { iconsUpdaterJob } from "./jobs/icons-updater"; 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 { 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"; import { createCronJobGroup } from "./lib";
const getJobGroup = () => { const getJobGroup = () => {
@@ -34,35 +8,9 @@ const getJobGroup = () => {
analytics: analyticsJob, analytics: analyticsJob,
iconsUpdater: iconsUpdaterJob, iconsUpdater: iconsUpdaterJob,
ping: pingJob, 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 { declare global {
var cronJobs: ReturnType<typeof getJobGroup> | undefined; var cronJobs: ReturnType<typeof getJobGroup> | undefined;
} }
-31
View File
@@ -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<WidgetComponentProps<"dockerContainers">["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 }));
}
}),
);
});
@@ -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: () => ({}),
},
}),
);
@@ -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,
}),
},
}),
);
@@ -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: () => ({}),
},
}),
);
@@ -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<string, never>) => {
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: () => ({}),
},
},
),
);
@@ -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,
}),
},
}),
);
@@ -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: () => ({}),
},
}),
);
@@ -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;
},
},
}),
);
@@ -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": () => ({}),
},
}),
);
@@ -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 }),
},
}),
);
@@ -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 }),
},
}),
);
@@ -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: () => ({}),
},
}),
);
@@ -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,
},
}),
);
@@ -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<WidgetComponentProps<"timetable">["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 }));
}
}
});
@@ -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: () => ({}),
},
}),
);
@@ -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 } : []),
},
}),
);
@@ -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: () => ({}),
},
}),
);
@@ -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<WidgetComponentProps<"minecraftServerStatus">["options"]>(item.options);
const innerHandler = minecraftServerStatusRequestHandler.handler(options);
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
}),
);
},
);
-39
View File
@@ -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<WidgetComponentProps<"rssFeed">["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 }));
}
}
}
});
@@ -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({});
});
-36
View File
@@ -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<WidgetComponentProps<"weather">["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 }));
}
}
});
@@ -0,0 +1,9 @@
CREATE TABLE `widget_secret` (
`item_id` varchar(64) NOT NULL,
`kind` varchar(64) NOT NULL,
`value` text NOT NULL,
`updated_at` timestamp NOT NULL,
CONSTRAINT `widget_secret_item_id_kind_pk` PRIMARY KEY(`item_id`,`kind`)
);
--> statement-breakpoint
ALTER TABLE `widget_secret` ADD CONSTRAINT `widget_secret_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -295,6 +295,13 @@
"when": 1780764500000, "when": 1780764500000,
"tag": "0041_merge_custom_widget_url", "tag": "0041_merge_custom_widget_url",
"breakpoints": true "breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,9 @@
CREATE TABLE "widget_secret" (
"item_id" varchar(64) NOT NULL,
"kind" varchar(64) NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "widget_secret_item_id_kind_pk" PRIMARY KEY("item_id","kind")
);
--> statement-breakpoint
ALTER TABLE "widget_secret" ADD CONSTRAINT "widget_secret_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -57,6 +57,13 @@
"when": 1780764500000, "when": 1780764500000,
"tag": "0007_merge_custom_widget_url", "tag": "0007_merge_custom_widget_url",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,8 @@
CREATE TABLE `widget_secret` (
`item_id` text NOT NULL,
`kind` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
PRIMARY KEY(`item_id`, `kind`),
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE cascade
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -281,6 +281,13 @@
"when": 1780764500000, "when": 1780764500000,
"tag": "0039_merge_custom_widget_url", "tag": "0039_merge_custom_widget_url",
"breakpoints": true "breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
} }
] ]
} }
@@ -60,8 +60,8 @@ export type HomarrDocumentationPath =
| "/docs/tags/connections" | "/docs/tags/connections"
| "/docs/tags/custom-widgets" | "/docs/tags/custom-widgets"
| "/docs/tags/customization" | "/docs/tags/customization"
| "/docs/tags/data-sources"
| "/docs/tags/data" | "/docs/tags/data"
| "/docs/tags/data-sources"
| "/docs/tags/database" | "/docs/tags/database"
| "/docs/tags/developer" | "/docs/tags/developer"
| "/docs/tags/development" | "/docs/tags/development"
@@ -99,8 +99,8 @@ export type HomarrDocumentationPath =
| "/docs/tags/redis" | "/docs/tags/redis"
| "/docs/tags/responsive" | "/docs/tags/responsive"
| "/docs/tags/roles" | "/docs/tags/roles"
| "/docs/tags/search-engines"
| "/docs/tags/search" | "/docs/tags/search"
| "/docs/tags/search-engines"
| "/docs/tags/secrets" | "/docs/tags/secrets"
| "/docs/tags/security" | "/docs/tags/security"
| "/docs/tags/self-signed" | "/docs/tags/self-signed"
@@ -132,15 +132,6 @@ export type HomarrDocumentationPath =
| "/docs/advanced/running-as-different-user" | "/docs/advanced/running-as-different-user"
| "/docs/advanced/single-sign-on" | "/docs/advanced/single-sign-on"
| "/docs/advanced/styling" | "/docs/advanced/styling"
| "/docs/category/advanced"
| "/docs/category/community"
| "/docs/category/developer-guides"
| "/docs/category/getting-started"
| "/docs/category/installation-1"
| "/docs/category/installation"
| "/docs/category/integrations"
| "/docs/category/management"
| "/docs/category/widgets"
| "/docs/community/donate" | "/docs/community/donate"
| "/docs/community/faq" | "/docs/community/faq"
| "/docs/community/get-in-touch" | "/docs/community/get-in-touch"
@@ -168,17 +159,13 @@ export type HomarrDocumentationPath =
| "/docs/integrations/archiveteam-warrior" | "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2" | "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf" | "/docs/integrations/audiobookshelf"
| "/docs/integrations/bazarr"
| "/docs/integrations/beszel" | "/docs/integrations/beszel"
| "/docs/integrations/codeberg"
| "/docs/integrations/coolify" | "/docs/integrations/coolify"
| "/docs/integrations/dash-dot" | "/docs/integrations/dash-dot"
| "/docs/integrations/deluge" | "/docs/integrations/deluge"
| "/docs/integrations/docker-hub"
| "/docs/integrations/docker" | "/docs/integrations/docker"
| "/docs/integrations/emby" | "/docs/integrations/emby"
| "/docs/integrations/github-containerregistry"
| "/docs/integrations/github"
| "/docs/integrations/gitlab"
| "/docs/integrations/glances" | "/docs/integrations/glances"
| "/docs/integrations/gluetun" | "/docs/integrations/gluetun"
| "/docs/integrations/gotify" | "/docs/integrations/gotify"
@@ -189,34 +176,33 @@ export type HomarrDocumentationPath =
| "/docs/integrations/jellyseerr" | "/docs/integrations/jellyseerr"
| "/docs/integrations/kubernetes" | "/docs/integrations/kubernetes"
| "/docs/integrations/lidarr" | "/docs/integrations/lidarr"
| "/docs/integrations/linux-server-io"
| "/docs/integrations/navidrome" | "/docs/integrations/navidrome"
| "/docs/integrations/nextcloud" | "/docs/integrations/nextcloud"
| "/docs/integrations/npm"
| "/docs/integrations/ntfy" | "/docs/integrations/ntfy"
| "/docs/integrations/nzbget" | "/docs/integrations/nzbget"
| "/docs/integrations/open-media-vault" | "/docs/integrations/open-media-vault"
| "/docs/integrations/opnsense" | "/docs/integrations/opnsense"
| "/docs/integrations/overseerr" | "/docs/integrations/overseerr"
| "/docs/integrations/paperless-ngx" | "/docs/integrations/paperless-ngx"
| "/docs/integrations/patchmon"
| "/docs/integrations/peanut" | "/docs/integrations/peanut"
| "/docs/integrations/pi-hole" | "/docs/integrations/pi-hole"
| "/docs/integrations/plex" | "/docs/integrations/plex"
| "/docs/integrations/prowlarr" | "/docs/integrations/prowlarr"
| "/docs/integrations/proxmox" | "/docs/integrations/proxmox"
| "/docs/integrations/q-bittorent" | "/docs/integrations/q-bittorent"
| "/docs/integrations/quay"
| "/docs/integrations/radarr" | "/docs/integrations/radarr"
| "/docs/integrations/readarr" | "/docs/integrations/readarr"
| "/docs/integrations/sabnzbd" | "/docs/integrations/sabnzbd"
| "/docs/integrations/search-ch"
| "/docs/integrations/seerr" | "/docs/integrations/seerr"
| "/docs/integrations/slskd" | "/docs/integrations/slskd"
| "/docs/integrations/sonarr" | "/docs/integrations/sonarr"
| "/docs/integrations/speedtest-tracker" | "/docs/integrations/speedtest-tracker"
| "/docs/integrations/synology"
| "/docs/integrations/tdarr" | "/docs/integrations/tdarr"
| "/docs/integrations/technitium-dns" | "/docs/integrations/technitium-dns"
| "/docs/integrations/tracearr" | "/docs/integrations/tracearr"
| "/docs/integrations/traefik"
| "/docs/integrations/transmission" | "/docs/integrations/transmission"
| "/docs/integrations/truenas" | "/docs/integrations/truenas"
| "/docs/integrations/umami" | "/docs/integrations/umami"
@@ -239,6 +225,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/anchor-note" | "/docs/widgets/anchor-note"
| "/docs/widgets/app" | "/docs/widgets/app"
| "/docs/widgets/audio-stats" | "/docs/widgets/audio-stats"
| "/docs/widgets/bazarr"
| "/docs/widgets/beszel-alerts" | "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-grid" | "/docs/widgets/beszel-system-grid"
| "/docs/widgets/beszel-system-stats" | "/docs/widgets/beszel-system-stats"
@@ -258,6 +245,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/immich-album-carousel" | "/docs/widgets/immich-album-carousel"
| "/docs/widgets/immich-server-stats" | "/docs/widgets/immich-server-stats"
| "/docs/widgets/indexer-manager" | "/docs/widgets/indexer-manager"
| "/docs/widgets/media-missing"
| "/docs/widgets/media-releases" | "/docs/widgets/media-releases"
| "/docs/widgets/media-request-list" | "/docs/widgets/media-request-list"
| "/docs/widgets/media-request-stats" | "/docs/widgets/media-request-stats"
@@ -269,15 +257,18 @@ export type HomarrDocumentationPath =
| "/docs/widgets/notebook" | "/docs/widgets/notebook"
| "/docs/widgets/notifications" | "/docs/widgets/notifications"
| "/docs/widgets/paperless-ngx" | "/docs/widgets/paperless-ngx"
| "/docs/widgets/patchmon"
| "/docs/widgets/releases" | "/docs/widgets/releases"
| "/docs/widgets/rss-feed" | "/docs/widgets/rss-feed"
| "/docs/widgets/smart-home-entity-state" | "/docs/widgets/smart-home-entity-state"
| "/docs/widgets/smart-home-execute-automation" | "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/speedtest-tracker" | "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price" | "/docs/widgets/stock-price"
| "/docs/widgets/system-disks"
| "/docs/widgets/system-resources" | "/docs/widgets/system-resources"
| "/docs/widgets/timetable" | "/docs/widgets/timetable"
| "/docs/widgets/tracearr" | "/docs/widgets/tracearr"
| "/docs/widgets/traefik"
| "/docs/widgets/umami" | "/docs/widgets/umami"
| "/docs/widgets/ups" | "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma" | "/docs/widgets/uptime-kuma"
@@ -55,8 +55,6 @@ export const IconPicker = ({
const [value, setValue] = useUncontrolled({ value: propsValue, onChange }); const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
const [query, setQuery] = useState(""); 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(() => { useEffect(() => {
setQuery(""); setQuery("");
}, [value]); }, [value]);
@@ -65,13 +63,8 @@ export const IconPicker = ({
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100); 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) || ""; 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( const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText }, { searchText },
{ placeholderData: (prev) => prev }, { 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 || ""; const inputValue = query || value || "";
// ponytail: show the existing icon as a left-section preview.
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />; const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
// ponytail: prop override -> real count -> loading text.
const placeholderText = const placeholderText =
placeholder || placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) || (data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more