diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 83b6b01f3..fbcd9d5c1 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
+ - 1.68.0
- 1.67.0
- 1.66.1
- 1.66.0
diff --git a/.github/workflows/deployment-docker-image.yml b/.github/workflows/deployment-docker-image.yml
index 359ca09d7..d80cb5e1f 100644
--- a/.github/workflows/deployment-docker-image.yml
+++ b/.github/workflows/deployment-docker-image.yml
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
- node-version: 24.17.0
+ node-version: 24.18.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
diff --git a/.gitignore b/.gitignore
index 1bb62e979..c22262f6d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
# dependencies
node_modules
+.pnpm-store/
.pnp
.pnp.js
.idea/
@@ -70,7 +71,10 @@ apps/nextjs/public/images/background.png
# personal agent config
.opencode
+.agents/
+skills-lock.json
# docusaurus
apps/docs/.docusaurus
apps/docs/build
+.pnpm-store
diff --git a/.mise.toml b/.mise.toml
index 0b18e941c..d6bd41fc8 100644
--- a/.mise.toml
+++ b/.mise.toml
@@ -1,2 +1,2 @@
[tools]
-node = "24.17.0"
+node = "24.18.0"
diff --git a/.nvmrc b/.nvmrc
index 1dd37d537..ca5c35005 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-24.17.0
+24.18.0
diff --git a/.opencode/changelog/2026-07-03.md b/.opencode/changelog/2026-07-03.md
new file mode 100644
index 000000000..6b9faf096
--- /dev/null
+++ b/.opencode/changelog/2026-07-03.md
@@ -0,0 +1,10 @@
+# Changes — 2026-07-03
+
+## PR #5984 Review And Dev Merge Fixes
+
+- **Why**: Make `feat/widget-store` mergeable with `dev` and address CodeRabbit review feedback before requesting another review.
+- **What**: Merged `origin/dev`, resolved `apps/docs/package.json` and `pnpm-lock.yaml`, and fixed workshop vote state handling, 404-only vote recovery, PocketBase filename URL encoding, required widget `$schema` validation, install-button localization, public author email fallback, placeholder product copy, and workshop safety/submission guidance.
+- **Files**: `apps/docs/src/components/store/useStore.ts`, `apps/docs/src/lib/pocketbase.ts`, `apps/docs/src/lib/store-schema.ts`, `apps/docs/src/lib/store-schema.spec.ts`, `apps/nextjs/src/components/store-install/install-from-store-buttons.tsx`, `packages/translation/src/lang/en.json`, `apps/store/pb_migrations/1750000002_marketplace_view.js`, `apps/docs/docs/management/store/index.mdx`, `PRODUCT.md`, `pnpm-lock.yaml`, `apps/docs/package.json`.
+- **Tests**: `pnpm lint` passed with warnings, `pnpm typecheck` passed, `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed. Full `pnpm test` failed on unrelated timeout/external-dependency tests: Immich icon fetch, Pi-hole container, MySQL migration, and Nextcloud hook.
+- **Docs**: Updated workshop management docs with a stronger safety warning and submission guidelines.
+- **Risks**: The commit includes a large required `dev` merge, so review should distinguish upstream merge changes from the scoped CodeRabbit fixes.
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index f0cdbe63c..8f1b6afce 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,7 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
- "esbenp.prettier-vscode",
+ "oxc.oxc-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint",
"lokalise.i18n-ally"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61183ce87..091f44325 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,36 @@
+## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
+
+### Bug Fixes
+
+* make source maps opt-in to restore v1.67.0 memory baseline ([57ef0af](https://github.com/homarr-labs/homarr/commit/57ef0af4a252ea39727caeba7e13587dabc6254e)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
+* revert production source maps to restore v1.67.0 memory baseline ([60a69e3](https://github.com/homarr-labs/homarr/commit/60a69e31efc7168b8f4309daf75954d7730d883b)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
+
+## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
+
+### Features
+
+* add VPN status widget ([#5812](https://github.com/homarr-labs/homarr/issues/5812)) ([a2bb53a](https://github.com/homarr-labs/homarr/commit/a2bb53a8b8fd17da3d2e23851b75d4e179f66225))
+* re-add Docker quick-access button in user menu ([#6054](https://github.com/homarr-labs/homarr/issues/6054)) ([abd1635](https://github.com/homarr-labs/homarr/commit/abd16354bc9ef3dddbb497fd16b03476535046ac)), closes [#2165](https://github.com/homarr-labs/homarr/issues/2165)
+* ship source maps for readable production stack traces ([#6031](https://github.com/homarr-labs/homarr/issues/6031)) ([89380f8](https://github.com/homarr-labs/homarr/commit/89380f81ddd132f5b1041a8f2c3b6c6b138923dd))
+
+### Bug Fixes
+
+* **beszel:** harden cache layer and prevent blank widgets ([#6052](https://github.com/homarr-labs/homarr/issues/6052)) ([49c2663](https://github.com/homarr-labs/homarr/commit/49c266374df12fea2404710232ced42e2de11dbf)), closes [#6030](https://github.com/homarr-labs/homarr/issues/6030)
+* **beszel:** improve stats date display, add click-to-stats modal, fix grid ordering ([#6026](https://github.com/homarr-labs/homarr/issues/6026)) ([55fb227](https://github.com/homarr-labs/homarr/commit/55fb227305fe51963e486ffb25c4ce6811a2f0e4))
+* **beszel:** leverage TanStack Query for stale-while-revalidate caching ([#6055](https://github.com/homarr-labs/homarr/issues/6055)) ([bbc4589](https://github.com/homarr-labs/homarr/commit/bbc4589c9e1608721fb58a6f9b03cca8d65c1c4a))
+* **beszel:** use 480m interval for 1w/30d stats views ([4383da3](https://github.com/homarr-labs/homarr/commit/4383da38ef634327c288626c58480cc1e5a2f805))
+* **beszel:** widget transparency, error handling, and cache invalidation ([#5993](https://github.com/homarr-labs/homarr/issues/5993)) ([39e4245](https://github.com/homarr-labs/homarr/commit/39e424563a3187dcf0dba5f654e2225be76aef51))
+* **icon-picker:** keep URL visible in edit mode, add SVG-first ordering ([#6051](https://github.com/homarr-labs/homarr/issues/6051)) ([e01571b](https://github.com/homarr-labs/homarr/commit/e01571b221812cd19a6d67ebe3cf44432104ec27))
+* **integrations:** prevent certificate trust button from submitting the form ([#6020](https://github.com/homarr-labs/homarr/issues/6020)) ([0f66bfd](https://github.com/homarr-labs/homarr/commit/0f66bfd6c128253ba644d80d7c908de5314cf997))
+* **manage/about:** bypass self-fetch + add manage error boundary (LLM exploration) ([#6009](https://github.com/homarr-labs/homarr/issues/6009)) ([19f9769](https://github.com/homarr-labs/homarr/commit/19f9769d9e669d56d793e9f6a0d265316113d743)), closes [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008)
+* **plex:** use series title for media releases instead of season label ([#6083](https://github.com/homarr-labs/homarr/issues/6083)) ([2443b89](https://github.com/homarr-labs/homarr/commit/2443b89b46f88fddbf41f0382a9f3bc5469c6fa8))
+* show app first in add item modal ([#6087](https://github.com/homarr-labs/homarr/issues/6087)) ([915c8a3](https://github.com/homarr-labs/homarr/commit/915c8a3501551fdccf786d0c34dc01107d873ce0))
+* **truenas:** report actual memory usage and pool free space ([#6017](https://github.com/homarr-labs/homarr/issues/6017)) ([f69e545](https://github.com/homarr-labs/homarr/commit/f69e545d6429aece0e55975c3b2426f20e91e545))
+* **unraid:** report disk free space instead of total size ([#6018](https://github.com/homarr-labs/homarr/issues/6018)) ([1fb97ee](https://github.com/homarr-labs/homarr/commit/1fb97ee9c6fbe656a7dd3a9d1e436125982533f4))
+* **widgets:** clean up health monitoring disk and size display ([#6019](https://github.com/homarr-labs/homarr/issues/6019)) ([e29c79a](https://github.com/homarr-labs/homarr/commit/e29c79a9028d9311dcb3d8323e0950a444589c7a))
+* **widgets:** hide health monitoring default tab option for non-proxmox integrations ([#6021](https://github.com/homarr-labs/homarr/issues/6021)) ([52df56b](https://github.com/homarr-labs/homarr/commit/52df56b19da8e0bcb6c2e5df47b7936ec63f3149))
+* **widgets:** hide system disk usage value when it overflows and show it in a tooltip ([#6022](https://github.com/homarr-labs/homarr/issues/6022)) ([a19c9d8](https://github.com/homarr-labs/homarr/commit/a19c9d88caacc86b42854c732818f0b5e5cc8dd5))
+
## [1.67.0](https://github.com/homarr-labs/homarr/compare/v1.66.1...v1.67.0) (2026-06-19)
### Features
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 000000000..c8a801420
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,96 @@
+# Changes
+
+## Goal
+
+Remove widget-only services from Homarr's global Integration system.
+
+The affected services are release providers and Search.ch. They are only used by specific widgets, so keeping them as global integrations added unnecessary setup, UI, database rows, docs pages, and integration package code.
+
+ArchiveTeam Warrior remains a proper integration but is no longer auto-seeded on first install.
+
+## Why
+
+These services do not need shared integration state.
+
+- Release providers are selected per Releases widget repository.
+- Search.ch is only used by the Timetable widget.
+
+Moving them into widget options makes configuration simpler and removes dead-weight integration code from `packages/integrations`.
+
+## What Changed
+
+### Release Providers
+
+- Added release provider definitions in `packages/definitions/src/release-provider.ts`.
+- Removed release providers from `IntegrationKind`.
+- Moved release-fetching code out of `packages/integrations` and into `packages/request-handler/src/release-providers.ts`.
+- Updated the Releases widget to store provider configuration directly in widget options:
+ - `provider`
+ - `identifier`
+ - `versionFilter`
+ - `providerUrl`
+- Updated the Releases widget editor to use a provider select instead of an integration select.
+- Updated Docker import mapping so `ghcr.io` maps to `gitHubContainerRegistry`.
+- GitHub Container Registry now uses the public OCI registry API and accepts full image references such as `ghcr.io/homarr-labs/homarr:latest`.
+
+### Timetable / Search.ch
+
+- Removed Search.ch as an integration.
+- Added a Timetable widget `baseUrl` option, defaulting to `https://search.ch`.
+- Moved Search.ch request/parsing logic into `packages/request-handler/src/timetable.ts`.
+- Updated the Timetable API to receive `baseUrl` directly from widget options.
+
+### ArchiveTeam Warrior
+
+- Kept as a proper integration (not removed).
+- Removed `defaultUrl` from the integration definition to prevent auto-seeding on first install.
+
+### Integration Cleanup
+
+- Removed creators, exports, docs references, and implementation files for the deleted integrations.
+- Removed unused dependencies from `packages/integrations/package.json`.
+
+### Database Migration
+
+- Added `packages/db/migrations/custom/0003_remove_widget_only_integrations.ts`.
+- The migration moves old integration-linked widget config into widget options, then deletes the old widget-only integration rows.
+- Updated the earlier release widget migration to use the new `provider` field instead of `providerIntegrationId`.
+
+### Docs
+
+- Updated Releases widget docs to describe providers instead of integrations.
+- Updated Timetable docs to remove Search.ch integration setup.
+- Removed obsolete integration docs pages for the deleted integration kinds.
+- Updated docs sitemap and integration docs mappings.
+
+### Translations
+
+- Added English labels for the new widget options:
+ - `widget.timetable.option.baseUrl.label`
+ - `widget.releases.option.repositories.providerUrl.label`
+
+## How It Works Now
+
+Release provider configuration lives on each Releases widget repository entry. The request handler resolves provider metadata and fetches releases directly from the provider-specific endpoint. GHCR uses anonymous OCI registry access for public packages.
+
+Timetable passes its widget URL option directly to its API router and request handler. No global integration lookup is needed.
+
+ArchiveTeam Warrior continues to use the standard integration system, but users must manually add the integration (it is not auto-created on install).
+
+## Deliberate Non-Goals
+
+- No provider secret/token support was added.
+- No new secret storage model was introduced.
+- No compatibility layer was kept in `packages/integrations`.
+
+Widget options are client-visible, so storing provider credentials there would be wrong. If authenticated provider requests are needed later, they need a separate secret-storage design.
+
+## Verification So Far
+
+- Focused typechecks passed for affected packages.
+- Full `pnpm turbo typecheck` passed.
+- Full `pnpm lint` passed with existing warnings.
+- Focused release provider normalization tests passed.
+- Widget translation test passed.
+- Live GHCR smoke checks passed for `ghcr.io/muchobien/pocketbase:latest` and `ghcr.io/homarr-labs/homarr:latest`.
+- `pnpm test packages/integrations/test/pi-hole.spec.ts` passed earlier, but failed on rebased reruns because Pi-hole reported `domainsBeingBlocked` as `-2`/`0` instead of `> 1`.
diff --git a/CODEOWNERS b/CODEOWNERS
index b798cfada..ad494df91 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1,7 +1,193 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
-# Exempt Renovate‑managed files (no owners)
+# Specific features / integrations
+# These owners come from the PR that introduced each integration implementation.
+# Specific rules override the global fallback because CODEOWNERS uses the last
+# matching pattern; do not include @homarr-labs/maintainers here unless they
+# should also be requested for that integration.
+
+# AdGuard Home - introduced in https://github.com/homarr-labs/homarr/pull/929 by @hillaliy
+apps/docs/docs/integrations/adguard-home @hillaliy
+packages/integrations/src/adguard-home @hillaliy
+
+# Anchor - introduced in https://github.com/homarr-labs/homarr/pull/5040 by @ohmzi
+apps/docs/docs/integrations/anchor @ohmzi
+packages/integrations/src/anchor @ohmzi
+
+# ArchiveTeam Warrior - introduced in https://github.com/homarr-labs/homarr/pull/5784 by @darbyjack
+apps/docs/docs/integrations/archive-team-warrior @darbyjack
+packages/integrations/src/archive-team-warrior @darbyjack
+
+# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
+apps/docs/docs/integrations/audiobookshelf @ajnart
+packages/integrations/src/audiobookshelf @ajnart
+
+# Beszel - introduced in https://github.com/homarr-labs/homarr/pull/5900 by @ajnart
+apps/docs/docs/integrations/beszel @ajnart
+packages/integrations/src/beszel @ajnart
+
+# Codeberg - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
+apps/docs/docs/integrations/codeberg @Aandree5
+packages/integrations/src/codeberg @Aandree5
+
+# Coolify - introduced in https://github.com/homarr-labs/homarr/pull/4789 by @Rhiz3K
+apps/docs/docs/integrations/coolify @Rhiz3K
+packages/integrations/src/coolify @Rhiz3K
+
+# Dash. - introduced in https://github.com/homarr-labs/homarr/pull/1541 by @manuel-rw
+apps/docs/docs/integrations/dash-dot @manuel-rw
+packages/integrations/src/dashdot @manuel-rw
+
+# Docker Hub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
+apps/docs/docs/integrations/docker-hub @Aandree5
+packages/integrations/src/docker-hub @Aandree5
+
+# Emby - introduced in https://github.com/homarr-labs/homarr/pull/2466 by @Meierschlumpf
+apps/docs/docs/integrations/emby @Meierschlumpf
+packages/integrations/src/emby @Meierschlumpf
+
+# GitHub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
+apps/docs/docs/integrations/github @Aandree5
+packages/integrations/src/github @Aandree5
+
+# GitHub Container Registry - introduced in https://github.com/homarr-labs/homarr/pull/3727 by @Aandree5
+apps/docs/docs/integrations/github-containerregistry @Aandree5
+packages/integrations/src/github-container-registry @Aandree5
+
+# GitLab - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
+apps/docs/docs/integrations/gitlab @Aandree5
+packages/integrations/src/gitlab @Aandree5
+
+# Glances - introduced in https://github.com/homarr-labs/homarr/pull/5102 by @manuel-rw
+apps/docs/docs/integrations/glances @manuel-rw
+packages/integrations/src/glances @manuel-rw
+
+# Gluetun - introduced in https://github.com/homarr-labs/homarr/pull/5812 by @Lok3sh-kumar
+apps/docs/docs/integrations/gluetun @Lok3sh-kumar
+packages/integrations/src/gluetun @Lok3sh-kumar
+
+# Gotify - introduced in https://github.com/homarr-labs/homarr/pull/5847 by @Nerdy-Technician
+apps/docs/docs/integrations/gotify @Nerdy-Technician
+packages/integrations/src/gotify @Nerdy-Technician
+
+# Home Assistant - introduced in https://github.com/homarr-labs/homarr/pull/578 by @manuel-rw
+apps/docs/docs/integrations/home-assistant @manuel-rw
+packages/integrations/src/homeassistant @manuel-rw
+
+# ICal - introduced in https://github.com/homarr-labs/homarr/pull/3980 by @NicolasNewman
+apps/docs/docs/integrations/ical @NicolasNewman
+packages/integrations/src/ical @NicolasNewman
+
+# Immich - introduced in https://github.com/homarr-labs/homarr/pull/5165 by @manuel-rw
+apps/docs/docs/integrations/immich @manuel-rw
+packages/integrations/src/immich @manuel-rw
+
+# Jellyfin - introduced in https://github.com/homarr-labs/homarr/pull/672 by @manuel-rw
+apps/docs/docs/integrations/jellyfin @manuel-rw
+packages/integrations/src/jellyfin @manuel-rw
+
+# Jellyseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
+apps/docs/docs/integrations/jellyseerr @manuel-rw
+packages/integrations/src/jellyseerr @manuel-rw
+
+# LinuxServer.io - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
+apps/docs/docs/integrations/linux-server-io @Aandree5
+packages/integrations/src/linuxserverio @Aandree5
+
+# Navidrome - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
+apps/docs/docs/integrations/navidrome @ajnart
+packages/integrations/src/navidrome @ajnart
+
+# Nextcloud - introduced in https://github.com/homarr-labs/homarr/pull/2501 by @manuel-rw
+apps/docs/docs/integrations/nextcloud @manuel-rw
+packages/integrations/src/nextcloud @manuel-rw
+
+# npm - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
+apps/docs/docs/integrations/npm @Aandree5
+packages/integrations/src/npm @Aandree5
+
+# ntfy - introduced in https://github.com/homarr-labs/homarr/pull/2900 by @itzTheMeow
+apps/docs/docs/integrations/ntfy @ajnart @manuel-rw
+packages/integrations/src/ntfy @ajnart @manuel-rw
+
+# OpenMediaVault - introduced in https://github.com/homarr-labs/homarr/pull/1142 by @hillaliy
+apps/docs/docs/integrations/open-media-vault @hillaliy
+packages/integrations/src/openmediavault @hillaliy
+
+# OPNsense - introduced in https://github.com/homarr-labs/homarr/pull/3424 by @Oupsman
+apps/docs/docs/integrations/opnsense @Oupsman
+packages/integrations/src/opnsense @Oupsman
+
+# Overseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
+apps/docs/docs/integrations/overseerr @manuel-rw
+packages/integrations/src/overseerr @manuel-rw
+
+# Paperless-ngx - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
+apps/docs/docs/integrations/paperless-ngx @ajnart
+packages/integrations/src/paperless-ngx @ajnart
+
+# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
+apps/docs/docs/integrations/peanut @afonsojramos
+packages/integrations/src/peanut @afonsojramos
+
+# Pi-hole - introduced in https://github.com/homarr-labs/homarr/pull/521 by @Meierschlumpf
+apps/docs/docs/integrations/pi-hole @Meierschlumpf
+packages/integrations/src/pi-hole @Meierschlumpf
+
+# Plex - introduced in https://github.com/homarr-labs/homarr/pull/1342 by @hillaliy
+apps/docs/docs/integrations/plex @hillaliy
+packages/integrations/src/plex @hillaliy
+
+# Prowlarr - introduced in https://github.com/homarr-labs/homarr/pull/965 by @hillaliy
+apps/docs/docs/integrations/prowlarr @hillaliy
+packages/integrations/src/prowlarr @hillaliy
+
+# Proxmox - introduced in https://github.com/homarr-labs/homarr/pull/1969 by @Meierschlumpf
+apps/docs/docs/integrations/proxmox @Meierschlumpf
+packages/integrations/src/proxmox @Meierschlumpf
+
+# Quay - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
+apps/docs/docs/integrations/quay @Aandree5
+packages/integrations/src/quay @Aandree5
+
+# search.ch - introduced in https://github.com/homarr-labs/homarr/pull/5180 by @Meierschlumpf
+apps/docs/docs/integrations/search-ch @Meierschlumpf
+packages/integrations/src/search-ch @Meierschlumpf
+
+# Speedtest Tracker - introduced in https://github.com/homarr-labs/homarr/pull/5363 by @kylegrieder
+apps/docs/docs/integrations/speedtest-tracker @kylegrieder
+packages/integrations/src/speedtest-tracker @kylegrieder
+
+# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
+apps/docs/docs/integrations/technitium-dns @lopsch
+packages/integrations/src/technitium @lopsch
+
+# Tracearr - introduced in https://github.com/homarr-labs/homarr/pull/5060 by @StrandedTurtle
+apps/docs/docs/integrations/tracearr @StrandedTurtle
+packages/integrations/src/tracearr @StrandedTurtle
+
+# TrueNAS - introduced in https://github.com/homarr-labs/homarr/pull/3745 by @manuel-rw
+apps/docs/docs/integrations/truenas @manuel-rw @afonsojramos
+packages/integrations/src/truenas @manuel-rw @afonsojramos
+
+# Umami - introduced in https://github.com/homarr-labs/homarr/pull/5362 by @febrile42
+apps/docs/docs/integrations/umami @febrile42
+packages/integrations/src/umami @febrile42
+
+# UniFi Controller - introduced in https://github.com/homarr-labs/homarr/pull/2236 by @pitschi
+apps/docs/docs/integrations/unifi-controller @pitschi
+packages/integrations/src/unifi-controller @pitschi
+
+# Unraid - introduced in https://github.com/homarr-labs/homarr/pull/4439 by @manuel-rw
+apps/docs/docs/integrations/unraid @manuel-rw
+packages/integrations/src/unraid @manuel-rw
+
+# Uptime Kuma - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
+apps/docs/docs/integrations/uptime-kuma @ajnart
+packages/integrations/src/uptime-kuma @ajnart
+
+# Exempt Renovate-managed files (no owners)
package.json
**/package.json
package-lock.json
diff --git a/Dockerfile b/Dockerfile
index d50756e39..e7b5b679c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:24.17.0-alpine AS base
+FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
-# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
-# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
-# directory structure) so the existing standalone COPY ships them, keeping traces readable.
-# See https://github.com/homarr-labs/homarr/issues/5891
-RUN cd apps/nextjs/.next/server && \
- find . -name '*.map' | while IFS= read -r map; do \
- dest="../standalone/apps/nextjs/.next/server/$map"; \
- mkdir -p "$(dirname "$dest")"; \
- cp "$map" "$dest"; \
- done
-
FROM base AS runner
WORKDIR /app
@@ -53,6 +42,7 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
+COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
@@ -75,9 +65,6 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
-# Tell Node to symbolicate stack traces using the shipped .map files so production logs
-# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
-ENV NODE_OPTIONS='--enable-source-maps'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
diff --git a/PRODUCT.md b/PRODUCT.md
index ba0f18e9c..629b0abb5 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -2,7 +2,7 @@
## Register
-product
+GitHub OAuth is used for workshop accounts so community members can submit, vote on, comment on, and report shared widgets or CSS themes without creating a separate Homarr-specific identity.
## Users
diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx
index 5f923cec8..7ac904bf4 100644
--- a/apps/docs/docs/getting-started/after-the-installation.mdx
+++ b/apps/docs/docs/getting-started/after-the-installation.mdx
@@ -28,6 +28,7 @@ Read our [glossary](/docs/getting-started/glossary) if you are new to Homarr.
Congratulations on installing Homarr. Now it's time to get yourself familiar with the concepts of Homarr and how to use it.
## Onboarding
+
The onboard will show the first time you start Homarr.
This process will help you with your first steps and adding some initial data, such as the administrator account.
@@ -45,50 +46,68 @@ You can either import a previous dashboard using the ZIP archive or start from s
You may refresh anytime and continue from a different device - the onboarding is saved immediately on the server for your convenience.
### Starting from scratch
-Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
-If credentials provider was selected, you'll be prompted to create an admin user for Homarr.
-This user cannot be deleted and has full control over Homarr and all of it's boards.
-You must choose an username and password (both can later be changed).
-We enforce strict password requirements, when you enter your password, to protect your instance if you accidentially
-expose it to the Internet.
-
+Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
+If the credentials provider was selected, you'll be prompted to create an admin user for Homarr.
+This user cannot be deleted and has full control over Homarr and all of its boards.
+You must choose a username and password (both can later be changed).
+We enforce strict password requirements when you enter your password to protect your instance if you accidentally
+expose it to the internet.
+
+
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
-This group will be used to synchronize users from your provider to Homarr.
-When a user is logged in through the provider the groups of him that match groups with any in Homarr will be added to the user.
-The name of the group has to match exactly the name of the group in the provider.
+This group will be used to synchronize users from your provider to Homarr.
+When a user logs in through the provider, any of their groups that match groups in Homarr will be assigned to the user.
+The name of the group must match exactly the name of the group in the provider.
-
+
Next, you can configure some basic settings for Homarr. We recommend to leave all settings at default for most users.
Here you can also disable analytics. Please note that our analytics are completly anonymous and is fully compliant with GDPR and PECR.
-
+
### Setting up integrations
After configuring server settings, the onboarding guides you through setting up your integrations.
#### Docker service discovery
+
If Docker is available (socket mounted), Homarr automatically detects running containers and matches them to known integration types.
Matching integrations are pre-selected in the integration list, saving you from manually picking each one.
#### URL templating
+
You can define a base URL template to auto-fill integration URLs in bulk.
Two modes are supported:
+
- **Subdomain mode** - e.g. `{app}.example.com` where `{app}` is replaced with the integration name
- **Port mode** - e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port
#### API key helper links
+
For integrations that require API keys (such as Sonarr, Radarr, SABnzbd, or Seerr), a helper link opens the relevant settings page in the target application so you can quickly copy your key.
#### Creating apps from Docker containers
+
You can optionally auto-create bookmark apps for all discovered Docker containers, making them immediately available on your board.
:::note
-Some niche and registry integrations (GitHub, npm, Docker Hub, GitLab, Codeberg, LinuxServer.io, GHCR, Quay, Search.ch) are hidden from the onboarding selection grid but remain available on the manage integrations page.
+Release providers and Search.ch are configured directly in their widgets instead of the integrations page.
:::
@@ -97,30 +116,41 @@ The onboarding can no longer be started again.
From here you can click on one of the provided links to continue your journey.
We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board).
-
+
### Importing a ZIP from version before 1.0.0
+
This is recommended when you used Homarr before and want to re-import your data.
It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0).
-
+
**How to export and import data**
-1. Navigate to your old Homarr, open your management pages, open the tools submenu and open ``Migrate to 1.0``.
+
+1. Navigate to your old Homarr, open your management pages, open the tools submenu and open `Migrate to 1.0`.
2. Check the contents that you want to export. We recommend you to export all data.
3. Click on the export data button
4. Save the ZIP file, that is being downloaded, on your device. If you selected "integrations" or "users", you also get an encryption / decryption key.
-Copy it and do not loose it!
-5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose ``Import from Homarr before 1.0``
+ Copy it and do not loose it!
+5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose `Import from Homarr before 1.0`
6. Drag and drop your previously downloaded ZIP archive into the file upload:
7. Next, choose the data that you want to import and how to import it. Since screen sizes were removed in 1.0, we create a new board for every screen size by default. We recommend you to only keep the best layout and adjust it later in 1.0.0 - but you can also import all of them and pick later.
8. If you previously exported integrations or users, you will be asked to decrypt the data safely using your obtained encryption token. Paste into the modal and confirm your action.
9. Next, you can [follow the same setup as when starting from scratch](#starting-from-scratch). After you completed the onboarding, all of your data will be migrated and available.
## Creating your first board
+
In Homarr, you organize your apps and widgets on boards.
Boards can be organized using the interactive drag and drop.
-Let's create your first board by navigating here: ``Manage`` > ``Boards`` > ``New board``:
+Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:

A board must always have a name and can either be public or private.
@@ -133,12 +163,13 @@ Congratulations - you've just created your first board. You can see boards, that

## Add your first app to Homarr
+
To add links to your dashboard, you must first add them as an "app".
Navigate to this page to add an app:

-Next, click on the ``New app`` button. You'll need to provide a name and an icon to create an app.
+Next, click on the `New app` button. You'll need to provide a name and an icon to create an app.
The name must be at least one character long but you can provide any image URL for the icon.
Homarr ships with over 11K icons - so you can just search by entering a search text instead.
@@ -149,8 +180,9 @@ After you created the app, you'll see in on the apps page:

## Arrange and organize your board
+
Let's add your freshly created app, from the previous step, to the board.
-To do this, edit your previously created board by clicking on the big ``Open board`` button:
+To do this, edit your previously created board by clicking on the big `Open board` button:

@@ -165,23 +197,38 @@ Here you have the following options in the header:
When entering the edit mode, you'll get some additional buttons to work with:
-
+
- **Add item**: Click this button to add new elements to your page.
- **Exit edit mode**: Click this btuton to save your changes and exit edit mode.
To continue, click the option "New Item":
-
+
Next, you can choose what item you'd like to add.
We will discuss the other options in the following sections - for now choose "App" to continue.
-
+
As soon as you chose the app, the modal will close and you'll get the following item on your board:
-
+
This is expected, because you haven't told Homarr what app it should show in this item.
To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu.
@@ -228,17 +275,18 @@ Be sure to save your changes by exiting edit mode!

## Adding your first integration
+
Now it's time to integrate some basic data into your board.
To do this, we first need to create an integration.
An integration is a connection between Homarr and a third party application - usually something that you run and deploy yourself.
-Navigate to ``Manage`` > ``Integrations`` (for example via your user menu).
+Navigate to `Manage` > `Integrations` (for example via your user menu).
As an example, we are going to use the public demo instance of **Dash.** - a popular application to monitor CPU / hardware usage.
Choose the **Dash.** integration from the list of available integrations:

-Next, choose a name (or leave it at default), enter ``https://dash.mauz.dev/`` as the URL in the form and click on ``Test connection and create``.
+Next, choose a name (or leave it at default), enter `https://dash.mauz.dev/` as the URL in the form and click on `Test connection and create`.
Pressing the save button will perform a test request to the integration, to confirm that the networking setup is working as expected.
Sometimes invalid configurations, firewalls and other mistakes can lead to connectivity problems - [follow this guide here](/docs/management/integrations/#troubleshooting-connection) if you experience issues.
@@ -254,13 +302,14 @@ the target application or your router.
:::
## Embed integration data using widgets
+
Let's add your integration, that you have configured in the section above, as a widget to your board.
First, navigate back to your board that you want to add it to.
Next, enter edit mode again and add another item to your board:

-Instead of choosing an app, let's choose ``System Health Monitoring`` this time.
+Instead of choosing an app, let's choose `System Health Monitoring` this time.
Click on "Add to board":

@@ -290,10 +339,12 @@ As with every change, don't forget to save your changes using the exit edit mode

## Grouping your items
+
As the last step, let's organize your freshly created items using two different options.
You can choose either or both of them on your boards.
### Using dynamic sections
+
Dynamic sections are a simple, resizable visual group to group items.

@@ -303,6 +354,7 @@ After adding a dynamic section, you can simply drag and drop items into the sect

### Using categories
+
Categories are more advanced and complicated than dynamic sections.
To create a category, open the add item modal and choose "new category":
@@ -329,22 +381,27 @@ You cannot reduce their horizontal width.
After completing the onboarding, Homarr provides interactive guided tours to help you discover key features.
### Management tour
+
When you first visit the management area (`/manage`), Homarr walks you through the main pages: boards, apps, integrations, and users (admin only).
Each page highlights the list view and the create action so you know where to find and add things.
### Board tour
+
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
### Tour controls
+
- Press **Enter** (or click **Next**) to advance through tour steps
- Tours auto-start on first visit and do not repeat once completed
- Tours are skipped on mobile screens (below `sm` breakpoint)
- You can **reset tours** from the user settings page under **Manage > Users > [Your user] > General**
## Next steps
+
You're done with the basic setup 🔥. Now it's time to organize and customize even more.
We highly recommend you to experiment with different options, styles and configurations.
See these curated pages for your next steps:
+
- Create new apps using the [apps management](/docs/management/apps/)
- Customize your board or add more boards using the [boards management](/docs/management/boards/)
- Integrate software that you use using the [integrations management](/docs/management/integrations/)
diff --git a/apps/docs/docs/getting-started/installation/helm.md b/apps/docs/docs/getting-started/installation/helm.md
index 8a3903bf1..d6a4b7342 100644
--- a/apps/docs/docs/getting-started/installation/helm.md
+++ b/apps/docs/docs/getting-started/installation/helm.md
@@ -2,9 +2,9 @@
-
+

-
+
A Helm chart to deploy homarr for Kubernetes
@@ -383,6 +383,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
+| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
@@ -395,7 +396,6 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
-| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
diff --git a/apps/docs/docs/integrations/archiveteam-warrior/index.mdx b/apps/docs/docs/integrations/archiveteam-warrior/index.mdx
new file mode 100644
index 000000000..27256aee8
--- /dev/null
+++ b/apps/docs/docs/integrations/archiveteam-warrior/index.mdx
@@ -0,0 +1,35 @@
+---
+title: "ArchiveTeam Warrior"
+description: "ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive."
+hide_title: true
+---
+
+import { IntegrationHeader } from "@site/src/components/integrations/header";
+import { AddingIntegration } from "@site/src/components/integrations/adding";
+import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
+import { archiveTeamWarriorIntegration } from ".";
+
+
+
+The ArchiveTeam Warrior integration lets Homarr connect to your ArchiveTeam Warrior instance so you can manage and monitor your archiving tasks directly from your dashboard.
+
+### Adding the integration
+
+
+
+### Secrets
+
+ArchiveTeam Warrior does not require authentication by default. If your instance is protected with basic authentication, you can provide a username and password.
+
+
diff --git a/apps/docs/docs/integrations/archiveteam-warrior/index.ts b/apps/docs/docs/integrations/archiveteam-warrior/index.ts
new file mode 100644
index 000000000..5a18f6f4f
--- /dev/null
+++ b/apps/docs/docs/integrations/archiveteam-warrior/index.ts
@@ -0,0 +1,9 @@
+import { IntegrationDefinition } from "@site/src/types";
+
+export const archiveTeamWarriorIntegration: IntegrationDefinition = {
+ name: "ArchiveTeam Warrior",
+ description:
+ "ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive.",
+ iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
+ path: "../../integrations/archiveteam-warrior",
+};
diff --git a/apps/docs/docs/integrations/codeberg/index.mdx b/apps/docs/docs/integrations/codeberg/index.mdx
deleted file mode 100644
index 98ca314c2..000000000
--- a/apps/docs/docs/integrations/codeberg/index.mdx
+++ /dev/null
@@ -1,37 +0,0 @@
----
-title: 'Codeberg'
-description: "Codeberg is a non-profit, community-driven platform for hosting and collaborating on software projects."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { codebergIntegration } from '.';
-import { releasesWidget } from '@site/docs/widgets/releases';
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
-
-### Secrets
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/codeberg/index.ts b/apps/docs/docs/integrations/codeberg/index.ts
deleted file mode 100644
index f2a43ad48..000000000
--- a/apps/docs/docs/integrations/codeberg/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-
-export const codebergIntegration: IntegrationDefinition = {
- name: "Codeberg",
- description:
- "Codeberg is a non-profit, community-driven platform for hosting and collaborating on software projects.",
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/codeberg.svg",
- path: "../../integrations/codeberg",
-};
diff --git a/apps/docs/docs/integrations/docker-hub/index.mdx b/apps/docs/docs/integrations/docker-hub/index.mdx
deleted file mode 100644
index a49d03047..000000000
--- a/apps/docs/docs/integrations/docker-hub/index.mdx
+++ /dev/null
@@ -1,37 +0,0 @@
----
-title: 'Docker Hub'
-description: "Docker Hub is a cloud-based registry service for sharing and managing Docker images."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { dockerHubIntegration } from '.';
-import { releasesWidget } from '@site/docs/widgets/releases';
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
-
-### Secrets
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/docker-hub/index.ts b/apps/docs/docs/integrations/docker-hub/index.ts
deleted file mode 100644
index 1a49feeae..000000000
--- a/apps/docs/docs/integrations/docker-hub/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-
-export const dockerHubIntegration: IntegrationDefinition = {
- name: "Docker Hub",
- description: "Docker Hub is a cloud-based registry service for sharing and managing Docker images.",
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg",
- path: "../../integrations/docker-hub",
-};
diff --git a/apps/docs/docs/integrations/github-containerregistry/index.mdx b/apps/docs/docs/integrations/github-containerregistry/index.mdx
deleted file mode 100644
index f4cb5b310..000000000
--- a/apps/docs/docs/integrations/github-containerregistry/index.mdx
+++ /dev/null
@@ -1,42 +0,0 @@
----
-title: 'GitHub Container Registry'
-description: "GitHub Container Registry is a platform for hosting and managing packages."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { gitHubContainerRegistryIntegration } from '.';
-import { releasesWidget } from '@site/docs/widgets/releases';
-
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
-
-### Secrets
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/github-containerregistry/index.ts b/apps/docs/docs/integrations/github-containerregistry/index.ts
deleted file mode 100644
index b88f426d3..000000000
--- a/apps/docs/docs/integrations/github-containerregistry/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-
-export const gitHubContainerRegistryIntegration: IntegrationDefinition = {
- name: "GitHub Container Registry",
- description: "GitHub Container Registry is a platform for hosting and managing packages.",
- iconUrl: {
- light: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github.svg",
- dark: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github-light.svg",
- },
- path: "../../integrations/github-packages",
-};
diff --git a/apps/docs/docs/integrations/github/index.mdx b/apps/docs/docs/integrations/github/index.mdx
deleted file mode 100644
index 39c9777b3..000000000
--- a/apps/docs/docs/integrations/github/index.mdx
+++ /dev/null
@@ -1,41 +0,0 @@
----
-title: 'GitHub'
-description: "GitHub is a web-based platform for version control and collaboration."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { githubIntegration } from '.';
-import { releasesWidget } from '@site/docs/widgets/releases';
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
-
-### Secrets
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/github/index.ts b/apps/docs/docs/integrations/github/index.ts
deleted file mode 100644
index 7bb9b6b5d..000000000
--- a/apps/docs/docs/integrations/github/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-
-export const githubIntegration: IntegrationDefinition = {
- name: "GitHub",
- description: "GitHub is a web-based platform for version control and collaboration.",
- iconUrl: {
- light: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github.svg",
- dark: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github-light.svg",
- },
- path: "../../integrations/github",
-};
diff --git a/apps/docs/docs/integrations/gitlab/index.mdx b/apps/docs/docs/integrations/gitlab/index.mdx
deleted file mode 100644
index edc77e1cd..000000000
--- a/apps/docs/docs/integrations/gitlab/index.mdx
+++ /dev/null
@@ -1,37 +0,0 @@
----
-title: 'GitLab'
-description: "GitLab is a web-based DevOps lifecycle tool that provides a Git repository manager."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { gitlabIntegration } from '.';
-import { releasesWidget } from '@site/docs/widgets/releases';
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
-
-### Secrets
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/gitlab/index.ts b/apps/docs/docs/integrations/gitlab/index.ts
deleted file mode 100644
index c252cd6ff..000000000
--- a/apps/docs/docs/integrations/gitlab/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-
-export const gitlabIntegration: IntegrationDefinition = {
- name: "GitLab",
- description: "GitLab is a web-based DevOps lifecycle tool that provides a Git repository manager.",
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitlab.svg",
- path: "../../integrations/gitlab",
-};
diff --git a/apps/docs/docs/integrations/gluetun/index.mdx b/apps/docs/docs/integrations/gluetun/index.mdx
new file mode 100644
index 000000000..7c936fc1f
--- /dev/null
+++ b/apps/docs/docs/integrations/gluetun/index.mdx
@@ -0,0 +1,52 @@
+---
+title: 'Gluetun'
+description: 'Connect Gluetun to Homarr as a VPN integration.'
+hide_title: true
+---
+
+import { IntegrationHeader } from '@site/src/components/integrations/header';
+import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
+import { AddingIntegration } from '@site/src/components/integrations/adding';
+import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
+import { gluetunIntegration } from '.';
+import { vpnWidget } from '@site/docs/widgets/vpn';
+
+
+
+[Gluetun](https://github.com/qdm12/gluetun) exposes a control server that Homarr can query to report the VPN tunnel
+status, DNS status, public IP address, and active provider details in the VPN widget.
+
+### Widgets & Capabilities
+
+
+
+### Setting up the Gluetun control server
+
+Refer to the [Gluetun control server documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication) to enable and configure authentication.
+
+### Adding the integration
+
+
+
+### Secrets
+
+Gluetun's control server can be left unprotected or secured with either an API key or HTTP Basic authentication.
+Choose the option that matches how your Gluetun instance integration is set up.
+
+
diff --git a/apps/docs/docs/integrations/gluetun/index.ts b/apps/docs/docs/integrations/gluetun/index.ts
new file mode 100644
index 000000000..6d2489a62
--- /dev/null
+++ b/apps/docs/docs/integrations/gluetun/index.ts
@@ -0,0 +1,8 @@
+import { IntegrationDefinition } from "@site/src/types";
+
+export const gluetunIntegration: IntegrationDefinition = {
+ name: "Gluetun",
+ description: "VPN control server integration for Homarr's VPN widget.",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg",
+ path: "../../integrations/gluetun",
+};
diff --git a/apps/docs/docs/integrations/linux-server-io/index.mdx b/apps/docs/docs/integrations/linux-server-io/index.mdx
deleted file mode 100644
index 426dc7f68..000000000
--- a/apps/docs/docs/integrations/linux-server-io/index.mdx
+++ /dev/null
@@ -1,34 +0,0 @@
----
-title: 'LinuxServer.io'
-description: "LinuxServer.io is a platform for managing and deploying containerized applications."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { linuxServerIoIntegration } from '.';
-import { releasesWidget } from '@site/docs/widgets/releases';
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
-
-### Secrets
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/linux-server-io/index.ts b/apps/docs/docs/integrations/linux-server-io/index.ts
deleted file mode 100644
index 82bc0fac6..000000000
--- a/apps/docs/docs/integrations/linux-server-io/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-
-export const linuxServerIoIntegration: IntegrationDefinition = {
- name: "LinuxServer.io",
- description: "LinuxServer.io is a platform for managing and deploying containerized applications.",
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg",
- path: "../../integrations/linux-server-io",
-};
diff --git a/apps/docs/docs/integrations/npm/index.mdx b/apps/docs/docs/integrations/npm/index.mdx
deleted file mode 100644
index c4d21135e..000000000
--- a/apps/docs/docs/integrations/npm/index.mdx
+++ /dev/null
@@ -1,34 +0,0 @@
----
-title: 'NPM'
-description: "NPM is a package manager for the JavaScript programming language, primarily used for managing Node.js packages."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { npmIntegration } from '.';
-import { releasesWidget } from '@site/docs/widgets/releases';
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
-
-### Secrets
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/npm/index.ts b/apps/docs/docs/integrations/npm/index.ts
deleted file mode 100644
index 34fbafb5c..000000000
--- a/apps/docs/docs/integrations/npm/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-
-export const npmIntegration: IntegrationDefinition = {
- name: "NPM",
- description:
- "NPM is a package manager for the JavaScript programming language, primarily used for managing Node.js packages.",
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/npm.svg",
- path: "../../integrations/npm",
-};
diff --git a/apps/docs/docs/integrations/quay/index.mdx b/apps/docs/docs/integrations/quay/index.mdx
deleted file mode 100644
index 9033f81cf..000000000
--- a/apps/docs/docs/integrations/quay/index.mdx
+++ /dev/null
@@ -1,37 +0,0 @@
----
-title: 'Quay'
-description: "Quay is a container registry for storing and managing container images."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { quayIntegration } from '.';
-import { releasesWidget } from '@site/docs/widgets/releases';
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
-
-### Secrets
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/quay/index.ts b/apps/docs/docs/integrations/quay/index.ts
deleted file mode 100644
index 315732bff..000000000
--- a/apps/docs/docs/integrations/quay/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-
-export const quayIntegration: IntegrationDefinition = {
- name: "Quay",
- description: "Quay is a container registry for storing and managing container images.",
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png",
- path: "../../integrations/quay",
-};
diff --git a/apps/docs/docs/integrations/search-ch/index.mdx b/apps/docs/docs/integrations/search-ch/index.mdx
deleted file mode 100644
index 6286d5cca..000000000
--- a/apps/docs/docs/integrations/search-ch/index.mdx
+++ /dev/null
@@ -1,28 +0,0 @@
----
-title: 'Search.ch'
-description: "Search.ch supports searching for timetables in Switzerland."
-hide_title: true
----
-
-import { IntegrationHeader } from '@site/src/components/integrations/header';
-import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
-import { AddingIntegration } from '@site/src/components/integrations/adding';
-import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
-import { IconDownload } from "@tabler/icons-react";
-import { searchChIntegration } from '.';
-import Alert from '@theme/Admonition';
-import { timetableWidget } from '@site/docs/widgets/timetable';
-
-
-
-
-### Widgets & Capabilities
-
-
-### Adding the integration
-
\ No newline at end of file
diff --git a/apps/docs/docs/integrations/search-ch/index.ts b/apps/docs/docs/integrations/search-ch/index.ts
deleted file mode 100644
index 6c5819d67..000000000
--- a/apps/docs/docs/integrations/search-ch/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { IntegrationDefinition } from "@site/src/types";
-export const searchChIntegration: IntegrationDefinition = {
- name: "Search.ch",
- description: "Search.ch supports searching for timetables in Switzerland.",
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png",
- path: "../../integrations/search-ch",
-};
diff --git a/apps/docs/docs/management/store/index.mdx b/apps/docs/docs/management/store/index.mdx
index 876736b52..960bb03f6 100644
--- a/apps/docs/docs/management/store/index.mdx
+++ b/apps/docs/docs/management/store/index.mdx
@@ -11,10 +11,12 @@ The [Community Workshop](pathname:///workshop) is where Homarr users share and d
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
you like, report abuse, and install entries directly into your own Homarr instance.
-:::info
+:::warn
-The workshop is community-contributed content. Always review CSS and widget JSON before
-installing it on your dashboard.
+The workshop contains community-made content. Widgets may contain unwanted or malicious content,
+and it is your duty to check and verify safety of the content. While we try to remove malicious
+content as best as we can, we take no responsibility. Always review the widget JSON and all CSS
+before installing it on your instance and take frequent backups as prevention measures.
:::
@@ -35,6 +37,12 @@ installing it on your dashboard.
Submissions are published immediately. Reported entries are reviewed and may be removed.
+## Submission guidelines
+
+Submissions must be useful to Homarr users and safe to install. Do not submit content that is
+malicious, deceptive, illegal, hateful, sexually explicit, gore, gambling-focused, spam, or content
+that primarily promotes unrelated products or services.
+
## Installing into Homarr
You can install workshop entries without leaving Homarr:
diff --git a/apps/docs/docs/widgets/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx
index 60095aabe..5db4e9b30 100644
--- a/apps/docs/docs/widgets/calendar/index.mdx
+++ b/apps/docs/docs/widgets/calendar/index.mdx
@@ -63,7 +63,9 @@ import withoutIntegrations from './img/without-integrations.png';
### Interactions
-- View calendar events by clicking on the marked dates.
+- Hover over a marked date to preview its events in a popover.
+- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
+- Click the date again or click outside to close the pinned popover.
### Adding the widget
diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.ts b/apps/docs/docs/widgets/immich-album-carousel/index.ts
index 327314c3d..f6c4b224c 100644
--- a/apps/docs/docs/widgets/immich-album-carousel/index.ts
+++ b/apps/docs/docs/widgets/immich-album-carousel/index.ts
@@ -9,9 +9,8 @@ export const immichAlbumCarouselWidget: WidgetDefinition = {
configuration: {
items: [
{
- name: "Album ID",
- description:
- "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'",
+ name: "Album",
+ description: "Select the Immich album to show in the slideshow.",
values: { type: "string" },
defaultValue: "",
},
@@ -25,7 +24,13 @@ export const immichAlbumCarouselWidget: WidgetDefinition = {
name: "Show photo info",
description: "Display date of the picture and the current index at the bottom of the slideshow",
values: { type: "boolean" },
- defaultValue: "yes",
+ defaultValue: "no",
+ },
+ {
+ name: "Randomize photos",
+ description: "Show photos in a random order instead of always starting with the same album order.",
+ values: { type: "boolean" },
+ defaultValue: "no",
},
],
},
diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx
index 7808756f0..faf386a13 100644
--- a/apps/docs/docs/widgets/releases/index.mdx
+++ b/apps/docs/docs/widgets/releases/index.mdx
@@ -1,69 +1,44 @@
---
-title: 'Releases'
-description: 'Displays a list of the current version of the given repositories with the given version regex.'
+title: "Releases"
+description: "Displays a list of the current version of the given repositories with the given version regex."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { releasesWidget } from '.';
-import { githubIntegration } from '@site/docs/integrations/github';
-import { gitlabIntegration } from '@site/docs/integrations/gitlab';
-import { dockerHubIntegration } from '@site/docs/integrations/docker-hub';
-import { npmIntegration } from '@site/docs/integrations/npm';
-import { codebergIntegration } from '@site/docs/integrations/codeberg';
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { releasesWidget } from ".";
-
+
-The releases widget enables you to track a list of repositories and see their respective latest release.
-It also supports importing releases from your local docker containers.
+The releases widget enables you to track a list of repositories and see their respective latest release.
+It also supports importing releases from your local docker containers.
### Screenshots
-import collapsibleCard from './img/collapsible.png';
-import fullCard from './img/full.png';
-import noDetailsCard from './img/no-details.png';
-import { quayIntegration } from '@site/docs/integrations/quay';
-import { gitHubContainerRegistryIntegration } from '@site/docs/integrations/github-containerregistry';
-import { linuxServerIoIntegration } from '@site/docs/integrations/linux-server-io';
+import collapsibleCard from "./img/collapsible.png";
+import fullCard from "./img/full.png";
+import noDetailsCard from "./img/no-details.png";
-
+
-### Supported Integrations
+### Supported Providers
-
+GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and LinuxServer.io are configured per repository in the widget.
+
+For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
+Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
### Adding the widget
### Configuration
+
:::note
@@ -74,4 +49,3 @@ Setting the Prefix to `v`, precision to `2` and the Suffix to `-alpha`, will mat
Setting the Prefix to `v`, precision to `3`, will match `v5.2.0` but not `v1.0.2-alpha` or `Beta v5.2.1`.
:::
-
diff --git a/apps/docs/docs/widgets/releases/index.ts b/apps/docs/docs/widgets/releases/index.ts
index dbe3c1271..1f2270e82 100644
--- a/apps/docs/docs/widgets/releases/index.ts
+++ b/apps/docs/docs/widgets/releases/index.ts
@@ -54,16 +54,23 @@ export const releasesWidget: WidgetDefinition = {
items: [
{
name: "Provider",
- description: "Integration to fetch the repository from.",
- values: "Select from the available integrations.",
+ description: "Provider to fetch the repository or package releases from.",
+ values: "Select from the available providers.",
defaultValue: "-",
},
{
name: "Identifier",
- description: 'The identifier of the repository, e.g. "homarr-labs/homarr".',
- values: "Any valid repository identifier.",
+ description: 'The identifier of the repository or package, e.g. "homarr-labs/homarr".',
+ values:
+ 'Any valid provider identifier. GHCR also accepts full image refs like "ghcr.io/owner/package:tag".',
defaultValue: "-",
},
+ {
+ name: "Provider URL",
+ description: "Base URL for the provider.",
+ values: "Any valid URL.",
+ defaultValue: "Provider default",
+ },
{
name: "Name",
description: "Display name of the repository.",
diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx
index fe9a1b11c..e9bdfd326 100644
--- a/apps/docs/docs/widgets/timetable/index.mdx
+++ b/apps/docs/docs/widgets/timetable/index.mdx
@@ -1,44 +1,40 @@
---
-title: 'Timetable'
-description: 'Displays departure times for a station.'
+title: "Timetable"
+description: "Displays departure times for a station."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { timetableWidget } from '.';
-import { searchChIntegration } from '@site/docs/integrations/search-ch';
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { timetableWidget } from ".";
-
-
+
The Timetable widget provides real-time departure times for a selected station.
### Screenshots
-import trainDepartures from './img/train-departures.png';
-import streetDepartures from './img/street-departures.png';
+import trainDepartures from "./img/train-departures.png";
+import streetDepartures from "./img/street-departures.png";
### Adding the widget
-### Supported Integrations
-
-
-
### Configuration
+
diff --git a/apps/docs/docs/widgets/timetable/index.ts b/apps/docs/docs/widgets/timetable/index.ts
index 6f36349ee..1cefb6bcd 100644
--- a/apps/docs/docs/widgets/timetable/index.ts
+++ b/apps/docs/docs/widgets/timetable/index.ts
@@ -8,6 +8,12 @@ export const timetableWidget: WidgetDefinition = {
path: "../../widgets/timetable",
configuration: {
items: [
+ {
+ name: "Search.ch URL",
+ description: "Base URL for the Search.ch timetable API.",
+ values: "Any valid URL.",
+ defaultValue: "https://search.ch",
+ },
{
name: "Station",
description: "Select the station",
diff --git a/apps/docs/docs/widgets/vpn/img/single.png b/apps/docs/docs/widgets/vpn/img/single.png
new file mode 100644
index 000000000..ea4b423e7
Binary files /dev/null and b/apps/docs/docs/widgets/vpn/img/single.png differ
diff --git a/apps/docs/docs/widgets/vpn/index.mdx b/apps/docs/docs/widgets/vpn/index.mdx
new file mode 100644
index 000000000..0aa6a1a16
--- /dev/null
+++ b/apps/docs/docs/widgets/vpn/index.mdx
@@ -0,0 +1,40 @@
+---
+title: 'VPN'
+description: 'Monitor the status of your VPN integrations.'
+hide_title: true
+---
+
+import { WidgetHeader } from '@site/src/components/widgets/header';
+import { AddingWidget } from '@site/src/components/widgets/adding';
+import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { vpnWidget } from '.';
+import { gluetunIntegration } from '@site/docs/integrations/gluetun';
+
+
+
+The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS
+services are up, the currently exposed public IP address, and the VPN provider and protocol being used.
+
+When multiple VPN integrations are attached, the widget renders one card per integration so you can compare their
+current status at a glance.
+
+### Screenshots
+
+import singleIntegrationImage from './img/single.png';
+
+
+
+
+
+### Supported Integrations
+
+
+
+### Adding the widget
+
+
diff --git a/apps/docs/docs/widgets/vpn/index.ts b/apps/docs/docs/widgets/vpn/index.ts
new file mode 100644
index 000000000..0f1590e5b
--- /dev/null
+++ b/apps/docs/docs/widgets/vpn/index.ts
@@ -0,0 +1,9 @@
+import { WidgetDefinition } from "@site/src/types";
+import { IconShieldLock } from "@tabler/icons-react";
+
+export const vpnWidget: WidgetDefinition = {
+ icon: IconShieldLock,
+ name: "VPN",
+ description: "Monitor the connection status, public IP, and provider details of your VPN integrations.",
+ path: "../../widgets/vpn",
+};
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 661c1fb8b..4cfde26a3 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -40,7 +40,7 @@
"docusaurus-plugin-image-zoom": "^3.0.1",
"leader-line-new": "^1.1.9",
"pocketbase": "catalog:",
- "postcss": "^8.5.15",
+ "postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7",
diff --git a/apps/docs/src/components/store/useStore.ts b/apps/docs/src/components/store/useStore.ts
index a6bac1999..c08400505 100644
--- a/apps/docs/src/components/store/useStore.ts
+++ b/apps/docs/src/components/store/useStore.ts
@@ -29,6 +29,9 @@ const sorters: Record
numbe
new: (a, b) => new Date(b.created).getTime() - new Date(a.created).getTime(),
};
+const isNotFoundError = (error: unknown) =>
+ typeof error === "object" && error !== null && "status" in error && error.status === 404;
+
export const useStore = (storeUrl: string) => {
const pb = useMemo(() => getPocketBase(storeUrl), [storeUrl]);
const [submissions, setSubmissions] = useState([]);
@@ -134,7 +137,6 @@ export const useStore = (storeUrl: string) => {
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
- // Optimistic update — instant UI feedback
setVotes((v) => {
const next = { ...v };
if (isToggleOff) delete next[submissionId];
@@ -151,27 +153,32 @@ export const useStore = (storeUrl: string) => {
),
);
- // Server validates in background
try {
const existing = prev?.id ? prev : undefined;
if (!existing) {
- await pb.collection("votes").create({ submission: submissionId, value, user: userId });
+ const created = await pb
+ .collection("votes")
+ .create({ submission: submissionId, value, user: userId });
+ setVotes((current) => ({ ...current, [submissionId]: created }));
} else if (isToggleOff) {
try {
await pb.collection("votes").delete(existing.id);
- } catch {
- // Already deleted server-side — fine
+ } catch (caught) {
+ if (!isNotFoundError(caught)) throw caught;
}
} else {
try {
- await pb.collection("votes").update(existing.id, { value });
- } catch {
- // Record gone server-side — recreate
- await pb.collection("votes").create({ submission: submissionId, value, user: userId });
+ const updated = await pb.collection("votes").update(existing.id, { value });
+ setVotes((current) => ({ ...current, [submissionId]: updated }));
+ } catch (caught) {
+ if (!isNotFoundError(caught)) throw caught;
+ const created = await pb
+ .collection("votes")
+ .create({ submission: submissionId, value, user: userId });
+ setVotes((current) => ({ ...current, [submissionId]: created }));
}
}
} catch (caught) {
- // Revert optimistic state
setVotes((v) => {
const next = { ...v };
if (prev) next[submissionId] = prev;
diff --git a/apps/docs/src/lib/pocketbase.ts b/apps/docs/src/lib/pocketbase.ts
index 07dc6f293..b825624b3 100644
--- a/apps/docs/src/lib/pocketbase.ts
+++ b/apps/docs/src/lib/pocketbase.ts
@@ -12,7 +12,7 @@ export const getPocketBase = (url: string): PocketBase => {
};
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
- `${baseUrl}/api/files/submissions/${submissionId}/${filename}`;
+ `${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
export interface StoreSubmission {
id: string;
diff --git a/apps/docs/src/lib/store-schema.spec.ts b/apps/docs/src/lib/store-schema.spec.ts
index 1ed24a485..bc5f4483b 100644
--- a/apps/docs/src/lib/store-schema.spec.ts
+++ b/apps/docs/src/lib/store-schema.spec.ts
@@ -25,6 +25,19 @@ describe("validateSubmissionContent", () => {
expect(result.success).toBe(false);
});
+ it("rejects widgets without a schema version", () => {
+ const widgetWithoutSchema = {
+ name: validWidget.name,
+ url: validWidget.url,
+ authType: validWidget.authType,
+ method: validWidget.method,
+ displayType: validWidget.displayType,
+ displayConfig: validWidget.displayConfig,
+ };
+
+ expect(validateSubmissionContent("widget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
+ });
+
it("rejects malformed widget JSON", () => {
expect(validateSubmissionContent("widget", "{ not json").success).toBe(false);
});
diff --git a/apps/docs/src/lib/store-schema.ts b/apps/docs/src/lib/store-schema.ts
index c355be234..c5a6b1fc0 100644
--- a/apps/docs/src/lib/store-schema.ts
+++ b/apps/docs/src/lib/store-schema.ts
@@ -26,7 +26,7 @@ const displayTypes = [
const widgetSchema = z
.object({
- $schema: z.literal(WIDGET_SCHEMA_VERSION).optional(),
+ $schema: z.literal(WIDGET_SCHEMA_VERSION),
name: z.string().min(1).max(128),
url: z.string().min(1),
authType: z.enum(authTypes),
diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts
index a00df48d8..636df3f52 100644
--- a/apps/nextjs/next.config.ts
+++ b/apps/nextjs/next.config.ts
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
},
output: "standalone",
reactStrictMode: true,
- // Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
- productionBrowserSourceMaps: true,
// react compiler breaks mantine-react-table, so disabled for now
//reactCompiler: true,
/** We already do typechecking as separate tasks in CI */
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
- // Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
- serverSourceMaps: true,
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index cfea600e7..36bb405ad 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -59,10 +59,11 @@
"@modelcontextprotocol/sdk": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
- "@tanstack/query-persist-client-core": "catalog:",
+ "@tanstack/query-async-storage-persister": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
+ "@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:",
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts
index 70d8581c7..1b9a011aa 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts
+++ b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts
@@ -1,49 +1,29 @@
-import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
-import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
-import { parse, stringify } from "superjson";
+import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { fetchApi } from "@homarr/api/client";
-import {
- getActiveQueryCacheBoardId,
- isPersistableWidgetQueryKey,
- queryCacheStoragePrefix,
-} from "@homarr/api/query-cache";
+import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
const queryCacheStorage = {
- async getItem(_key) {
- return null;
- },
- async setItem(key, value) {
+ getItem: (_key: string) => null as string | null,
+ async setItem(_key: string, value: string) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
-
- try {
- await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
- } catch {
- // Query persistence is best-effort; failed writes should not affect widget rendering.
- }
+ await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
+ console.warn("[query-cache] persist failed", error);
+ });
},
- async removeItem(key) {
+ async removeItem(_key: string) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
-
- try {
- await fetchApi.queryCache.removeItem.mutate({ boardId, key });
- } catch {
- // Query persistence is best-effort; failed removals should not affect widget rendering.
- }
+ await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
+ console.warn("[query-cache] remove failed", error);
+ });
},
-} satisfies AsyncStorage;
+};
export const createWidgetQueryPersister = () =>
- experimental_createQueryPersister({
+ createAsyncStoragePersister({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
- maxAge: Number.POSITIVE_INFINITY,
- prefix: queryCacheStoragePrefix,
- refetchOnRestore: true,
- serialize: (query) => stringify(query),
- deserialize: (value) => parse(value),
- filters: {
- predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
- },
+ key: queryCacheStoragePrefix,
+ throttleTime: 2000,
});
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
index d09acd8c8..7a02e9284 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
+++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
@@ -2,9 +2,10 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
+import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
createWSClient,
httpBatchStreamLink,
@@ -21,7 +22,12 @@ import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
-import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
+import {
+ isPersistableWidgetQueryKey,
+ queryCacheDefaultGcTimeMs,
+ queryCacheDefaultRefetchIntervalMs,
+ queryCacheDefaultStaleTimeMs,
+} from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
@@ -29,7 +35,6 @@ import { showWarningNotification } from "@homarr/notifications";
import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => {
- // window is not defined on server side
if (typeof window === "undefined") {
return "ws";
}
@@ -55,32 +60,36 @@ const wsClient = createWSClient({
});
export function TRPCReactProvider(props: PropsWithChildren) {
- const [queryPersister] = useState(() => createWidgetQueryPersister());
- const [queryClient] = useState(
- () =>
- new QueryClient({
- defaultOptions: {
- queries: {
- persister: queryPersister.persisterFn,
- staleTime: queryCacheDefaultStaleTimeMs,
- },
- mutations: {
- onError(error) {
- if (
- error instanceof TRPCClientError &&
- error.data?.code === "FORBIDDEN" &&
- error.message === "Mutations are disabled in demo mode"
- ) {
- showWarningNotification({
- title: "Demo mode",
- message: "This action is disabled in demo mode.",
- });
- }
- },
+ const [persister] = useState(() => createWidgetQueryPersister());
+ const [queryClient] = useState(() => {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: queryCacheDefaultStaleTimeMs,
+ gcTime: queryCacheDefaultGcTimeMs,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ retry: 3,
+ },
+ mutations: {
+ onError(error) {
+ if (
+ error instanceof TRPCClientError &&
+ error.data?.code === "FORBIDDEN" &&
+ error.message === "Mutations are disabled in demo mode"
+ ) {
+ showWarningNotification({
+ title: "Demo mode",
+ message: "This action is disabled in demo mode.",
+ });
+ }
},
},
- }),
- );
+ },
+ });
+ client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
+ return client;
+ });
const [trpcClient] = useState(() => {
return clientApi.createClient({
@@ -98,9 +107,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
true: httpLink({
- /**
- * We don't want to transform the data here as we want to use form data
- */
transformer: {
serialize(object: unknown) {
return object;
@@ -115,7 +121,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: httpBatchStreamLink({
transformer: superjson,
url: getTrpcUrl(),
- maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length
+ maxURLLength: 2083,
headers: createHeadersCallbackForSource("nextjs-react (json)"),
}),
}),
@@ -126,10 +132,20 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return (
-
+
+ query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
+ },
+ }}
+ >
{props.children}
-
+
);
}
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
index 2144304c5..aff5e395c 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
@@ -5,10 +5,8 @@ import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
-import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
-import { parse } from "superjson";
-
+import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
@@ -17,7 +15,7 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
-import { getAllQueryCacheAsync } from "@homarr/redis";
+import { getQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -118,36 +116,17 @@ export const createBoardContentPage = >(
};
};
-interface PersistedEntry {
- state: { data: unknown; dataUpdatedAt: number; status: string };
- queryKey: unknown[];
- queryHash: string;
-}
-
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
- const entries = await getAllQueryCacheAsync(userId, boardId);
- const queries: DehydratedState["queries"] = [];
+ const serialized = await getQueryCacheAsync(userId, boardId);
+ if (!serialized) return null;
- for (const serialized of Object.values(entries)) {
- try {
- const persisted = parse(serialized);
- if (persisted.state.status !== "success") continue;
+ const persisted = JSON.parse(serialized) as PersistedClient;
+ if (!persisted?.clientState?.queries?.length) return null;
- queries.push({
- state: persisted.state as DehydratedState["queries"][number]["state"],
- queryKey: persisted.queryKey,
- queryHash: persisted.queryHash,
- });
- } catch {
- // skip malformed entries
- }
- }
-
- if (queries.length === 0) return null;
-
- return ;
- } catch {
+ return ;
+ } catch (error) {
+ logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
return null;
}
}
diff --git a/apps/nextjs/src/app/[locale]/manage/about/page.tsx b/apps/nextjs/src/app/[locale]/manage/about/page.tsx
index 3f49db49c..9577d39a0 100644
--- a/apps/nextjs/src/app/[locale]/manage/about/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/about/page.tsx
@@ -1,4 +1,3 @@
-import { headers } from "next/headers";
import Image from "next/image";
import {
Accordion,
@@ -41,11 +40,10 @@ import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
-import type { PackageJsonDependencies } from "~/versions/package-reader";
-import { getPackageVersion } from "~/versions/package-reader";
-import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
-import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
-import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
+import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
+import githubContributors from "@static-data/contributors.json";
+import openCollectiveContributors from "@static-data/opencollective-contributors.json";
+import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css";
export async function generateMetadata() {
@@ -56,32 +54,10 @@ export async function generateMetadata() {
};
}
-const getHostAsync = async () => {
- if (process.env.HOSTNAME) {
- return `${process.env.HOSTNAME}:3000`;
- }
-
- return (await headers()).get("host");
-};
-
export default async function AboutPage() {
- const baseServerUrl = `http://${await getHostAsync()}`;
const t = await getScopedI18n("management.page.about");
const version = getPackageVersion();
- const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
- res.json(),
- )) as PackageJsonDependencies;
- const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
- res.json(),
- )) as typeof githubContributorsJson;
-
- const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
- res.json(),
- )) as typeof crowdinContributorsJson;
-
- const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
- (res) => res.json(),
- )) as typeof openCollectiveContributorsJson;
+ const dependencies = await getDependenciesAsync();
return (
@@ -185,7 +161,7 @@ export default async function AboutPage() {
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
- .sort(([key1], [key2]) => key1.localeCompare(key2))
+ .toSorted(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
void;
+}) {
+ const tError = useScopedI18n("management.error");
+ const t = useI18n();
+
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+
+ {tError("title")}
+
+ {tError("text")}
+
+ {error.digest && (
+
+ {tError("digest", { digest: error.digest })}
+
+ )}
+
+ {t("common.action.tryAgain")}
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx
index bdd86cc7c..db8050ff1 100644
--- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx
@@ -10,6 +10,7 @@ import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
+import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
@@ -79,17 +80,20 @@ export default async function ApiPage() {
const toolGroups = getMcpToolGroups();
return (
-
- }
- authenticationPanel={ }
- baseUrl={baseUrl}
- hasApiKeys={apiKeys.length > 0}
- toolGroups={toolGroups}
- />
-
+ <>
+
+
+ }
+ authenticationPanel={ }
+ baseUrl={baseUrl}
+ hasApiKeys={apiKeys.length > 0}
+ toolGroups={toolGroups}
+ />
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx
index 80a794b57..037c11677 100644
--- a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx
@@ -6,6 +6,7 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
+import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
@@ -31,10 +32,13 @@ export default async function BackupPage() {
const t = await getScopedI18n("management.page.tool.backup");
return (
-
- {t("title")}
-
-
-
+ <>
+
+
+ {t("title")}
+
+
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
index d2c1def97..43bbc3ff3 100644
--- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
@@ -149,35 +150,39 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef[]
},
];
-export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
+interface DockerTableProps {
+ initialData?: RouterOutputs["docker"]["getContainers"];
+}
+
+export function DockerTable({ initialData }: DockerTableProps) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
- const { data } = clientApi.docker.getContainers.useQuery(undefined, {
- initialData: { containers, timestamp },
+ const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
+ initialData,
refetchOnMount: false,
- refetchOnWindowFocus: false,
- refetchOnReconnect: false,
- });
- const relativeTime = useTimeAgo(data.timestamp);
- const utils = clientApi.useUtils();
- const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
- async onSuccess() {
- await utils.docker.getContainers.invalidate();
- showSuccessNotification({
- title: tDocker("action.refresh.notification.success.title"),
- message: tDocker("action.refresh.notification.success.message"),
- });
- },
- onError() {
- showErrorNotification({
- title: tDocker("action.refresh.notification.error.title"),
- message: tDocker("action.refresh.notification.error.message"),
- });
- },
});
+ const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
+ const mutate = useCallback(() => {
+ void refetch().then((result) => {
+ if (result.isError) {
+ showErrorNotification({
+ title: tDocker("action.refresh.notification.error.title"),
+ message: tDocker("action.refresh.notification.error.message"),
+ });
+ } else {
+ showSuccessNotification({
+ title: tDocker("action.refresh.notification.success.title"),
+ message: tDocker("action.refresh.notification.success.message"),
+ });
+ }
+ });
+ }, [refetch, tDocker]);
+
+ const containers = data?.containers ?? [];
const table = useTranslatedMantineReactTable({
- data: data.containers,
+ data: containers,
+ state: { isLoading: isFetching },
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
@@ -190,7 +195,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
- placeholder: tDocker("table.search", { count: String(data.containers.length) }),
+ placeholder: tDocker("table.search", { count: String(containers.length) }),
style: { minWidth: 300 },
autoFocus: true,
},
@@ -200,7 +205,12 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
renderRowActions: ({ row }: { row: MRT_Row }) => ,
renderTopToolbarCustomActions: () => (
- } onClick={() => mutate()} loading={isPending}>
+ }
+ onClick={() => mutate()}
+ loading={isFetching}
+ >
{tDocker("action.refresh.label")}
),
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
index 1da3db128..97544314d 100644
--- a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
@@ -23,7 +23,7 @@ export default async function DockerPage() {
{tDocker("title")}
-
+
>
);
diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/layout.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/layout.tsx
index fd6766417..3c8724068 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/layout.tsx
@@ -7,6 +7,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
+import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
@@ -24,6 +25,7 @@ export default async function Layout(props: PropsWithChildren) {
return (
+
diff --git a/apps/nextjs/src/app/api/about/contributors/crowdin/route.ts b/apps/nextjs/src/app/api/about/contributors/crowdin/route.ts
index a149c082d..d55165906 100644
--- a/apps/nextjs/src/app/api/about/contributors/crowdin/route.ts
+++ b/apps/nextjs/src/app/api/about/contributors/crowdin/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-import crowdinContributors from "../../../../../../../../static-data/translators.json";
+import crowdinContributors from "@static-data/translators.json";
export const GET = () => {
return NextResponse.json(crowdinContributors);
diff --git a/apps/nextjs/src/app/api/about/contributors/github/route.ts b/apps/nextjs/src/app/api/about/contributors/github/route.ts
index a6fc81173..fa93d2881 100644
--- a/apps/nextjs/src/app/api/about/contributors/github/route.ts
+++ b/apps/nextjs/src/app/api/about/contributors/github/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-import githubContributors from "../../../../../../../../static-data/contributors.json";
+import githubContributors from "@static-data/contributors.json";
export const GET = () => {
return NextResponse.json(githubContributors);
diff --git a/apps/nextjs/src/app/api/about/contributors/opencollective/route.ts b/apps/nextjs/src/app/api/about/contributors/opencollective/route.ts
index 369f14a8c..eac1b58c4 100644
--- a/apps/nextjs/src/app/api/about/contributors/opencollective/route.ts
+++ b/apps/nextjs/src/app/api/about/contributors/opencollective/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json";
+import openCollectiveContributors from "@static-data/opencollective-contributors.json";
export const GET = () => {
return NextResponse.json(openCollectiveContributors);
diff --git a/apps/nextjs/src/app/api/backup/test/backup.spec.ts b/apps/nextjs/src/app/api/backup/test/backup.spec.ts
index 0a394abf7..15706ecb8 100644
--- a/apps/nextjs/src/app/api/backup/test/backup.spec.ts
+++ b/apps/nextjs/src/app/api/backup/test/backup.spec.ts
@@ -211,7 +211,17 @@ describe("SQLite backup", () => {
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
- expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
+ // The old key must no longer recover the plaintext. With AES-256-CBC this
+ // usually fails PKCS7 padding validation (throws), but ~1/256 of the time the
+ // wrong key yields valid padding and returns garbage instead of throwing, so
+ // assert on the actual property (plaintext is unrecoverable) rather than throwing.
+ let decryptedWithOldKey: string | null = null;
+ try {
+ decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey);
+ } catch {
+ // Expected for most wrong keys: padding validation fails.
+ }
+ expect(decryptedWithOldKey).not.toBe(plaintext);
sqlite.close();
});
diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts
index a59e4335b..ee92a4464 100644
--- a/apps/nextjs/src/components/backup/use-backup-analysis.ts
+++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts
@@ -142,7 +142,8 @@ export const useBackupAnalysis = () => {
phase: "error",
});
throw new Error(
- `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
+ `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
+ { cause: migrationErr },
);
}
diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx
index 9f56a7d48..d869538e4 100644
--- a/apps/nextjs/src/components/board/items/item-content.tsx
+++ b/apps/nextjs/src/components/board/items/item-content.tsx
@@ -1,6 +1,8 @@
+import type { MutableRefObject } from "react";
+import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
-import { QueryErrorResetBoundary } from "@tanstack/react-query";
+import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import { ErrorBoundary } from "react-error-boundary";
@@ -16,6 +18,7 @@ import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
+import { WidgetContextMenu } from "./widget-context-menu";
interface BoardItemContentProps {
item: SectionItem;
@@ -30,30 +33,33 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize();
const board = useRequiredBoard();
+ const widgetStateRef = useRef | null>(null);
return (
<>
-
-
-
+
+
+
+
+
{item.advancedOptions.title?.trim() && (
| null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -98,12 +105,16 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
+ const queryClient = useQueryClient();
return (
{({ reset }) => (
{
+ void queryClient.invalidateQueries({ queryKey: [["widget"]] });
+ reset();
+ }}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx
index 1c0c4100a..b8a136bbb 100644
--- a/apps/nextjs/src/components/board/items/item-menu.tsx
+++ b/apps/nextjs/src/components/board/items/item-menu.tsx
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
+ itemId: item.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx
index 726f135fa..2ac349c6d 100644
--- a/apps/nextjs/src/components/board/items/item-select-modal.tsx
+++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx
@@ -41,7 +41,12 @@ export const ItemSelectModal = createModal(({ actions }) => {
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
- .sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
+ .sort((itemA, itemB) => {
+ if (itemA.kind === "app") return -1;
+ if (itemB.kind === "app") return 1;
+
+ return itemA.name.localeCompare(itemB.name);
+ }),
[t],
);
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
new file mode 100644
index 000000000..415151a52
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import type { MutableRefObject, ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Group, Menu, Switch, Text } from "@mantine/core";
+import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
+import type { QueryClient } from "@tanstack/react-query";
+import { useQueryClient } from "@tanstack/react-query";
+
+import { clientApi } from "@homarr/api/client";
+import { useSession } from "@homarr/auth/client";
+import { useRequiredBoard } from "@homarr/boards/context";
+import { useEditMode } from "@homarr/boards/edit-mode";
+import { useConfirmModal, useModalAction } from "@homarr/modals";
+import { useSettings } from "@homarr/settings";
+import { translateIfNecessary } from "@homarr/translation";
+import { useI18n, useScopedI18n } from "@homarr/translation/client";
+import type { WidgetContextMenuAction } from "@homarr/widgets";
+import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
+import { WidgetEditModal } from "@homarr/widgets/modals";
+
+import type { SectionItem } from "~/app/[locale]/boards/_types";
+import { useSectionContext } from "../sections/section-context";
+import { useItemActions } from "./item-actions";
+import { ItemMoveModal } from "./item-move-modal";
+
+interface WidgetContextMenuProps {
+ item: SectionItem;
+ widgetStateRef: MutableRefObject | null>;
+ children: ReactNode;
+}
+
+export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
+ const { data: session } = useSession();
+ const [isEditMode] = useEditMode();
+ const board = useRequiredBoard();
+ const tItem = useScopedI18n("item");
+ const tMenu = useScopedI18n("item.menu.label");
+ const t = useI18n();
+ const settings = useSettings();
+ const { openModal } = useModalAction(WidgetEditModal);
+ const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
+ const { openConfirmModal } = useConfirmModal();
+ const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
+ useItemActions();
+ const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
+ const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
+ const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
+ const { gridstack } = useSectionContext().refs;
+ const queryClient = useQueryClient();
+
+ const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
+ const handleRefetch = useCallback(() => {
+ void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
+ }, [queryClient, widgetQueryKey]);
+
+ const options = useMemo(
+ () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record,
+ [item.kind, settings, item.options],
+ );
+
+ type OptionDef = { type: string; skipContextMenu?: boolean };
+ const toggleOptions = useMemo(() => {
+ const rawOptions = currentDefinition.createOptions(settings) as unknown as Record;
+ return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
+ }, [currentDefinition, settings]);
+
+ const widgetContextActions = useMemo(() => {
+ const def = currentDefinition as Record;
+ if (typeof def.contextActions !== "function") return [];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const actions = (def.contextActions as any)({
+ options,
+ setOptions: (partial: Record) => {
+ updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
+ },
+ integrationIds: item.integrationIds,
+ context: { isEditMode, boardId: board.id, itemId: item.id },
+ widgetStateRef,
+ });
+ return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
+ }, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
+
+ const persistBoard = useCallback(
+ (updatedItems: typeof board.items) => {
+ saveBoard({ ...board, items: updatedItems });
+ },
+ [board, saveBoard],
+ );
+
+ const openEditModal = useCallback(() => {
+ openModal(
+ {
+ kind: item.kind,
+ value: {
+ advancedOptions: item.advancedOptions,
+ options: item.options,
+ integrationIds: item.integrationIds,
+ },
+ onSuccessfulEdit: (editResult) => {
+ updateItemOptions({ itemId: item.id, newOptions: editResult.options });
+ updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
+ updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
+ if (!isEditMode) {
+ persistBoard(
+ board.items.map((boardItem) =>
+ boardItem.id !== item.id
+ ? boardItem
+ : {
+ ...boardItem,
+ options: editResult.options,
+ advancedOptions: editResult.advancedOptions,
+ integrationIds: editResult.integrationIds,
+ },
+ ),
+ );
+ }
+ },
+ integrationData: (integrationData ?? []).filter(
+ (integration) =>
+ "supportedIntegrations" in currentDefinition &&
+ (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
+ ),
+ integrationSupport: "supportedIntegrations" in currentDefinition,
+ settings,
+ appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ { title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
+ );
+ }, [
+ openModal,
+ item,
+ updateItemOptions,
+ updateItemAdvancedOptions,
+ updateItemIntegrations,
+ integrationData,
+ currentDefinition,
+ settings,
+ isEditMode,
+ persistBoard,
+ board,
+ ]);
+
+ const handleToggle = useCallback(
+ (key: string) => (checked: boolean) => {
+ const newOptions = { ...options, [key]: checked };
+ updateItemOptions({ itemId: item.id, newOptions });
+ persistBoard(
+ board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
+ );
+ },
+ [options, item.id, updateItemOptions, persistBoard, board],
+ );
+
+ if (!session) return <>{children}>;
+
+ const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
+
+ return (
+
+ {children}
+
+ {toggleOptions.length > 0 && (
+ <>
+ {tMenu("options")}
+ {toggleOptions.map(([key]) => (
+ handleToggle(key)(!options[key])}>
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+
+ {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
+
+
+
+ ))}
+ >
+ )}
+
+ {visibleWidgetActions.length > 0 && (
+ <>
+ {toggleOptions.length > 0 && }
+ {tMenu("actions")}
+ {visibleWidgetActions.map((action) => {
+ const Icon = action.icon;
+ return (
+ : undefined}
+ onClick={action.onClick}
+ disabled={action.disabled}
+ color={action.color}
+ >
+ {String(translateIfNecessary(t, action.label))}
+
+ );
+ })}
+ >
+ )}
+
+ {isEditMode && (
+ <>
+ {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && }
+ }
+ onClick={() => {
+ if (!gridstack.current) return;
+ openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
+ }}
+ >
+ {tItem("action.moveResize")}
+
+ }
+ onClick={() => duplicateItem({ itemId: item.id })}
+ >
+ {tItem("action.duplicate")}
+
+ >
+ )}
+
+ <>
+ {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && }
+ } onClick={handleRefetch}>
+
+ {tMenu("refresh")}
+
+
+
+
+
+ }
+ onClick={openEditModal}
+ disabled={isPending}
+ >
+ {tMenu("settings")}
+
+ >
+
+ {isEditMode && (
+ <>
+
+ }
+ onClick={() => {
+ openConfirmModal({
+ title: tItem("remove.title"),
+ children: tItem("remove.message"),
+ onConfirm: () => removeItem({ itemId: item.id }),
+ });
+ }}
+ >
+ {tItem("action.remove")}
+
+ >
+ )}
+
+
+ );
+};
+
+const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
+ const [, setTick] = useState(0);
+ useEffect(() => {
+ const id = setInterval(() => setTick((n) => n + 1), 1000);
+ return () => clearInterval(id);
+ }, []);
+
+ const timestamps = queryClient
+ .getQueryCache()
+ .findAll({ queryKey })
+ .map((q) => q.state.dataUpdatedAt)
+ .filter(Boolean);
+ if (timestamps.length === 0) return null;
+
+ const oldest = Math.min(...timestamps);
+ const seconds = Math.floor((Date.now() - oldest) / 1000);
+ if (seconds < 5) return "just now";
+ if (seconds < 60) return `${seconds}s ago`;
+ return `${Math.floor(seconds / 60)}m ago`;
+};
diff --git a/apps/nextjs/src/components/board/sections/item.module.css b/apps/nextjs/src/components/board/sections/item.module.css
index f679c62ca..a7168659e 100644
--- a/apps/nextjs/src/components/board/sections/item.module.css
+++ b/apps/nextjs/src/components/board/sections/item.module.css
@@ -10,3 +10,8 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
+
+.itemCard:global(.iframe-wrapper) {
+ background-color: transparent !important;
+ border-color: transparent !important;
+}
diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts
index 4b43a4299..7451af1e7 100644
--- a/apps/nextjs/src/components/integration/integration-grid-shared.ts
+++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts
@@ -48,15 +48,14 @@ export const categoryTranslationKeys: Record = {
search: "integration.category.search",
mediaTranscoding: "integration.category.mediaTranscoding",
networkController: "integration.category.networkController",
- releasesProvider: "integration.category.releasesProvider",
notifications: "integration.category.notifications",
firewall: "integration.category.firewall",
- timetable: "integration.category.timetable",
photoService: "integration.category.photoService",
notes: "integration.category.notes",
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
+ vpn: "integration.category.vpn",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
diff --git a/apps/nextjs/src/components/layout/header/docker-quick-access-modal.tsx b/apps/nextjs/src/components/layout/header/docker-quick-access-modal.tsx
new file mode 100644
index 000000000..c9826b8a9
--- /dev/null
+++ b/apps/nextjs/src/components/layout/header/docker-quick-access-modal.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import { createModal } from "@homarr/modals";
+
+import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
+
+export const DockerQuickAccessModal = createModal(() => ).withOptions({
+ defaultTitle: (t) => t("docker.title"),
+ size: 1400,
+});
diff --git a/apps/nextjs/src/components/layout/header/user.tsx b/apps/nextjs/src/components/layout/header/user.tsx
index 6ec2cf306..af3d72121 100644
--- a/apps/nextjs/src/components/layout/header/user.tsx
+++ b/apps/nextjs/src/components/layout/header/user.tsx
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
+import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
export const UserButton = async () => {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
+ const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
-
+
}>
diff --git a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx
index d200aa966..2ed6bc5f8 100644
--- a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx
+++ b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx
@@ -14,6 +14,8 @@ interface DynamicBreadcrumbProps {
nonInteractable?: string[];
}
+const categorySegments = new Set(["tools"]);
+
/**
* Breadcrumb is client side rendered. Elements are automatically
* calculated and translated using dynamic keys.
@@ -60,8 +62,11 @@ export const DynamicBreadcrumb = ({
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart);
+ const isNonInteractable =
+ nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
+
if (mappedValue) {
- if (nonInteractable?.includes(pathnamePart)) {
+ if (isNonInteractable) {
return {mappedValue} ;
}
return (
@@ -71,7 +76,7 @@ export const DynamicBreadcrumb = ({
);
}
- if (nonInteractable?.includes(pathnamePart)) {
+ if (isNonInteractable) {
return {t(`${translationKey}.label` as TranslationKeys)} ;
}
diff --git a/apps/nextjs/src/components/store-install/install-from-store-buttons.tsx b/apps/nextjs/src/components/store-install/install-from-store-buttons.tsx
index 88c810b5a..6be0e050f 100644
--- a/apps/nextjs/src/components/store-install/install-from-store-buttons.tsx
+++ b/apps/nextjs/src/components/store-install/install-from-store-buttons.tsx
@@ -8,6 +8,7 @@ import { IconBuildingStore } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
+import { useScopedI18n } from "@homarr/translation/client";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -28,19 +29,20 @@ function parseWidgetContent(content: string) {
}
export const InstallWidgetFromStoreButton = () => {
+ const t = useScopedI18n("customWidget.workshop");
const [opened, { open, close }] = useDisclosure(false);
const [pendingId, setPendingId] = useState(null);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
- showSuccessNotification({ title: "Workshop", message: "Custom widget installed" });
+ showSuccessNotification({ title: t("title"), message: t("notification.widgetInstalled") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
setPendingId(null);
close();
},
onError: (err) => {
- showErrorNotification({ title: "Workshop", message: err.message || "Failed to install the custom widget" });
+ showErrorNotification({ title: t("title"), message: err.message || t("notification.widgetInstallFailed") });
setPendingId(null);
},
});
@@ -48,7 +50,7 @@ export const InstallWidgetFromStoreButton = () => {
const handleSelect = (submission: { id: string; content: string }) => {
const parsed = parseWidgetContent(submission.content);
if (!parsed.ok) {
- showErrorNotification({ title: "Workshop", message: parsed.message });
+ showErrorNotification({ title: t("title"), message: parsed.message });
return;
}
setPendingId(submission.id);
@@ -58,13 +60,13 @@ export const InstallWidgetFromStoreButton = () => {
return (
<>
} onClick={open}>
- Install from Workshop
+ {t("action.installFromWorkshop")}
@@ -73,18 +75,19 @@ export const InstallWidgetFromStoreButton = () => {
};
export const InstallCssFromStoreButton = ({ onSelect }: { onSelect: (css: string) => void }) => {
+ const t = useScopedI18n("customWidget.workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
} onClick={open}>
- Install from Workshop
+ {t("action.installFromWorkshop")}
{
onSelect(submission.content);
close();
diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx
index 71ec2b218..cf6d013a2 100644
--- a/apps/nextjs/src/components/user-avatar-menu.tsx
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
-import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
+import {
+ IconBrandDocker,
+ IconCheck,
+ IconHome,
+ IconLogin,
+ IconLogout,
+ IconSettings,
+ IconTool,
+} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
+import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps {
children: ReactNode;
availableUpdatesPromise?: Promise;
+ isDockerEnabled?: boolean;
}
-export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
+export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal);
+ const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
await signOut({
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
}>
{t("management")}
+ {isDockerEnabled && (
+ } onClick={() => openDockerModal()}>
+ {t("docker")}
+
+ )}
>
)}
diff --git a/apps/nextjs/src/versions/package-reader.ts b/apps/nextjs/src/versions/package-reader.ts
index d62c97b20..bf1cd6405 100644
--- a/apps/nextjs/src/versions/package-reader.ts
+++ b/apps/nextjs/src/versions/package-reader.ts
@@ -35,14 +35,22 @@ export const getDependenciesAsync = async (): Promise =
type DependencyCatalog = Map;
const parseDependencyCatalogsAsync = async (): Promise => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
- const workspaceConfigContent = await fsPromises.readFile(
- path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
- "utf-8",
- );
- const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
- const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
+ const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
- return new Map(Object.entries(parseResult.catalog ?? {}));
+ try {
+ const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8");
+ const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
+ const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
+ return new Map(Object.entries(parseResult.catalog ?? {}));
+ } catch (error) {
+ // The workspace config is absent in Docker (only the runner stage is shipped),
+ // so a missing file is expected. Surface any other failure (e.g. malformed YAML).
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return new Map();
+ }
+
+ throw error;
+ }
};
const workspaceConfigCatalogSchema = z.object({
diff --git a/apps/nextjs/tsconfig.json b/apps/nextjs/tsconfig.json
index 470a11e34..59c34075e 100644
--- a/apps/nextjs/tsconfig.json
+++ b/apps/nextjs/tsconfig.json
@@ -3,6 +3,7 @@
"compilerOptions": {
"paths": {
"~/*": ["./src/*"],
+ "@static-data/*": ["../../static-data/*"],
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
},
"plugins": [
diff --git a/apps/store/pb_migrations/1750000002_marketplace_view.js b/apps/store/pb_migrations/1750000002_marketplace_view.js
index d7ee10a8b..5595394d1 100644
--- a/apps/store/pb_migrations/1750000002_marketplace_view.js
+++ b/apps/store/pb_migrations/1750000002_marketplace_view.js
@@ -21,7 +21,7 @@ migrate(
s.author,
s.created,
s.updated,
- COALESCE(u.name, u.email, 'unknown') as authorName,
+ COALESCE(NULLIF(u.name, ''), 'unknown') as authorName,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) as upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) as downvotes,
(SELECT COUNT(*) FROM comments c WHERE c.submission = s.id) as commentCount
diff --git a/apps/tasks/src/on-start/index.ts b/apps/tasks/src/on-start/index.ts
index cb9564d1b..cc6967dce 100644
--- a/apps/tasks/src/on-start/index.ts
+++ b/apps/tasks/src/on-start/index.ts
@@ -1,7 +1,7 @@
-import { invalidateUpdateCheckerCacheAsync } from "./invalidate-update-checker-cache";
import { cleanupSessionsAsync } from "./session-cleanup";
+import { warmUpdateCheckerAsync } from "./warm-update-checker";
export async function onStartAsync() {
await cleanupSessionsAsync();
- await invalidateUpdateCheckerCacheAsync();
+ await warmUpdateCheckerAsync();
}
diff --git a/apps/tasks/src/on-start/invalidate-update-checker-cache.ts b/apps/tasks/src/on-start/invalidate-update-checker-cache.ts
deleted file mode 100644
index 470989dcf..000000000
--- a/apps/tasks/src/on-start/invalidate-update-checker-cache.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { createLogger } from "@homarr/core/infrastructure/logs";
-import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
-
-const logger = createLogger({ module: "invalidateUpdateCheckerCache" });
-
-/**
- * Invalidates the update checker cache on startup to ensure fresh data.
- * It is important as we want to avoid showing pending updates after the update to latest version.
- */
-export async function invalidateUpdateCheckerCacheAsync() {
- try {
- const handler = updateCheckerRequestHandler.handler({});
- await handler.invalidateAsync();
- logger.debug("Update checker cache invalidated");
- } catch (error) {
- logger.error(new Error("Failed to invalidate update checker cache", { cause: error }));
- }
-}
diff --git a/apps/tasks/src/on-start/warm-update-checker.ts b/apps/tasks/src/on-start/warm-update-checker.ts
new file mode 100644
index 000000000..a3b9f94a9
--- /dev/null
+++ b/apps/tasks/src/on-start/warm-update-checker.ts
@@ -0,0 +1,14 @@
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
+
+const logger = createLogger({ module: "warmUpdateChecker" });
+
+export async function warmUpdateCheckerAsync() {
+ try {
+ const handler = updateCheckerRequestHandler.handler({});
+ await handler.getDataAsync();
+ logger.debug("Update checker warmed");
+ } catch (error) {
+ logger.error(new Error("Failed to warm update checker", { cause: error }));
+ }
+}
diff --git a/deployments/prebuilt-debian/Dockerfile b/deployments/prebuilt-debian/Dockerfile
index cfebe0a56..9d6e6af9d 100644
--- a/deployments/prebuilt-debian/Dockerfile
+++ b/deployments/prebuilt-debian/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:24.17.0-trixie AS base
+FROM node:24.18.0-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
diff --git a/deployments/prebuilt-debian/package.json b/deployments/prebuilt-debian/package.json
index 8d59f2567..0374dd9a6 100644
--- a/deployments/prebuilt-debian/package.json
+++ b/deployments/prebuilt-debian/package.json
@@ -5,8 +5,8 @@
"better-sqlite3": "^12.10.0"
},
"engines": {
- "node": ">=24.17.0",
- "pnpm": ">=11.8.0"
+ "node": ">=24.18.0",
+ "pnpm": ">=11.9.0"
},
- "packageManager": "pnpm@11.8.0"
+ "packageManager": "pnpm@11.9.0"
}
diff --git a/docs/README.md b/docs/README.md
index 8e37dfdc6..99aa947bf 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -102,20 +102,13 @@
-
-
-
- Codeberg
-
-
-
Coolify
-
-
+
+
@@ -130,13 +123,6 @@
-
-
-
- Docker Hub
-
-
-
@@ -144,27 +130,6 @@
-
-
-
- Github
-
-
-
-
-
-
- GitHub Container Registry
-
-
-
-
-
-
- Gitlab
-
-
-
@@ -172,6 +137,13 @@
+
+
+
+ Gluetun
+
+
+
@@ -184,8 +156,8 @@
Home Assistant
-
-
+
+
@@ -205,8 +177,8 @@
Jellyfin
-
-
+
+
@@ -221,13 +193,6 @@
-
-
-
- LinuxServer.io
-
-
-
@@ -240,22 +205,15 @@
Nextcloud
-
-
-
-
-
- NPM
-
-
-
+
+
ntfy
-
-
+
+
@@ -296,15 +254,15 @@
PeaNUT
-
-
+
+
Pi-hole
-
-
+
+
@@ -333,13 +291,6 @@
-
-
-
- Quay
-
-
-
@@ -361,13 +312,6 @@
-
-
-
- Search.ch
-
-
-
@@ -401,15 +345,15 @@
Tdarr
-
-
+
+
Technitium DNS
-
-
+
+
@@ -450,8 +394,8 @@
Unraid
-
-
+
+
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 000000000..d6bd41fc8
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,2 @@
+[tools]
+node = "24.18.0"
diff --git a/package.json b/package.json
index 37bf34fd5..a568ea9dc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "homarr",
- "version": "1.67.0",
+ "version": "1.68.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -34,6 +34,9 @@
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
+ "dependencies": {
+ "jsonpath-plus": "catalog:"
+ },
"devDependencies": {
"@playwright/test": "catalog:",
"@semantic-release/changelog": "catalog:",
@@ -62,11 +65,8 @@
"vitest": "catalog:"
},
"engines": {
- "node": ">=24.17.0",
- "pnpm": ">=11.8.0"
+ "node": ">=24.18.0",
+ "pnpm": ">=11.9.0"
},
- "packageManager": "pnpm@11.8.0",
- "dependencies": {
- "jsonpath-plus": "catalog:"
- }
+ "packageManager": "pnpm@11.9.0"
}
diff --git a/packages/analytics/src/send-server-analytics.ts b/packages/analytics/src/send-server-analytics.ts
index 2a961d94c..b851f86ec 100644
--- a/packages/analytics/src/send-server-analytics.ts
+++ b/packages/analytics/src/send-server-analytics.ts
@@ -36,8 +36,6 @@ const logger = createLogger({ module: "analytics" });
type AnalyticsResult = "sent" | "disabled" | "failed";
-// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
-// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL.
const getOrCreateInstanceId = async (
analyticsSettings: Awaited>>,
): Promise => {
diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts
index 887e878ad..b39e4a97d 100644
--- a/packages/api/src/mcp.ts
+++ b/packages/api/src/mcp.ts
@@ -17,6 +17,7 @@ import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { smartHomeRouter } from "./router/widgets/smart-home";
+import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
export const mcpRouter = createTRPCRouter({
app: appRouterForApps,
@@ -35,4 +36,5 @@ export const mcpRouter = createTRPCRouter({
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
smartHome: smartHomeRouter,
+ widgetSecrets: widgetSecretsRouter,
});
diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts
index 690059723..965c21aae 100644
--- a/packages/api/src/query-cache.ts
+++ b/packages/api/src/query-cache.ts
@@ -1,7 +1,8 @@
import type { QueryKey } from "@tanstack/react-query";
-export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
-export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
+export const queryCacheDefaultStaleTimeMs = 0;
+export const queryCacheDefaultRefetchIntervalMs = 30_000;
+export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts
index 0011545d0..44b4ce381 100644
--- a/packages/api/src/router/docker/docker-router.ts
+++ b/packages/api/src/router/docker/docker-router.ts
@@ -25,9 +25,7 @@ export const dockerRouter = createTRPCRouter({
.concat(dockerMiddleware())
.query(async () => {
const innerHandler = dockerContainersRequestHandler.handler({});
- const result = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ const result = await innerHandler.getDataAsync();
const { data, timestamp } = result;
@@ -36,26 +34,6 @@ export const dockerRouter = createTRPCRouter({
timestamp,
};
}),
- subscribeContainers: permissionRequiredProcedure
- .requiresPermission("admin")
- .concat(dockerMiddleware())
- .subscription(() => {
- return observable((emit) => {
- const innerHandler = dockerContainersRequestHandler.handler({});
- const unsubscribe = innerHandler.subscribe((data) => {
- emit.next(data);
- });
-
- return unsubscribe;
- });
- }),
- invalidate: permissionRequiredProcedure
- .requiresPermission("admin")
- .concat(dockerMiddleware())
- .mutation(async () => {
- const innerHandler = dockerContainersRequestHandler.handler({});
- await innerHandler.invalidateAsync();
- }),
startAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -73,9 +51,6 @@ export const dockerRouter = createTRPCRouter({
await container.start();
}),
);
-
- const innerHandler = dockerContainersRequestHandler.handler({});
- await innerHandler.invalidateAsync();
}),
stopAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -94,9 +69,6 @@ export const dockerRouter = createTRPCRouter({
await container.stop();
}),
);
-
- const innerHandler = dockerContainersRequestHandler.handler({});
- await innerHandler.invalidateAsync();
}),
restartAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -116,9 +88,6 @@ export const dockerRouter = createTRPCRouter({
await container.restart();
}),
);
-
- const innerHandler = dockerContainersRequestHandler.handler({});
- await innerHandler.invalidateAsync();
}),
removeAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -138,9 +107,6 @@ export const dockerRouter = createTRPCRouter({
await container.remove();
}),
);
-
- const innerHandler = dockerContainersRequestHandler.handler({});
- await innerHandler.invalidateAsync();
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
diff --git a/packages/api/src/router/icons.ts b/packages/api/src/router/icons.ts
index 4f0e59d33..6758dd012 100644
--- a/packages/api/src/router/icons.ts
+++ b/packages/api/src/router/icons.ts
@@ -30,8 +30,6 @@ export const iconsRouter = createTRPCRouter({
columns: { id: true, name: true, url: true },
where: whereCondition,
orderBy: (table, { asc, sql }) => {
- // ponytail: SVG first, then alphabetical; with a search term,
- // prepend a relevance tier (exact > prefix > substring).
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name);
if (term.length === 0) {
diff --git a/packages/api/src/router/query-cache.ts b/packages/api/src/router/query-cache.ts
index 6143501fc..e2aa77b9b 100644
--- a/packages/api/src/router/query-cache.ts
+++ b/packages/api/src/router/query-cache.ts
@@ -2,53 +2,41 @@ import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
-import { createQueryCacheChannel } from "@homarr/redis";
+import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
-import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache";
+import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
-const queryCacheKeyInput = z.object({
+const queryCacheBoardInput = z.object({
boardId: z.string().min(1),
- key: z.string().min(1).max(4096),
});
-const queryCacheSetInput = queryCacheKeyInput.extend({
+const queryCacheSetInput = queryCacheBoardInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
-const createChannel = (input: z.infer, userId: string) =>
- createQueryCacheChannel({
- userId,
- boardId: input.boardId,
- key: input.key,
- ttlMs: queryCacheRetentionMs,
- maxValueBytes: queryCacheMaxValueBytes,
- });
-
const validateBoardAccessAsync = async (ctx: Parameters[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
- getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => {
- await validateBoardAccessAsync(ctx, input.boardId);
-
- const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
- return await channel.getAsync();
- }),
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
-
- const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
+ const userId = ctx.session?.user.id ?? "anonymous";
return {
- stored: await channel.setAsync(input.value),
+ stored: await setQueryCacheAsync(
+ userId,
+ input.boardId,
+ input.value,
+ queryCacheDefaultGcTimeMs,
+ queryCacheMaxValueBytes,
+ ),
};
}),
- removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => {
+ removeItem: publicProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
-
- const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
- await channel.removeAsync();
+ const userId = ctx.session?.user.id ?? "anonymous";
+ await removeQueryCacheAsync(userId, input.boardId);
}),
});
diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts
index cbea4063b..4a5a32a88 100644
--- a/packages/api/src/router/test/docker/docker-router.spec.ts
+++ b/packages/api/src/router/test/docker/docker-router.spec.ts
@@ -23,28 +23,12 @@ vi.mock("@homarr/request-handler/docker", () => ({
},
dockerContainersRequestHandler: {
handler: () => ({
- getCachedOrUpdatedDataAsync: async () => {
- return await Promise.resolve({ containers: [] });
- },
- invalidateAsync: async () => {
- return await Promise.resolve();
+ getDataAsync: async () => {
+ return await Promise.resolve({ data: [], timestamp: new Date() });
},
}),
},
}));
-vi.mock("@homarr/redis", () => ({
- createCacheChannel: () => ({
- // eslint-disable-next-line @typescript-eslint/require-await
- consumeAsync: async () => ({
- timestamp: new Date().toISOString(),
- data: { containers: [] },
- }),
- // eslint-disable-next-line @typescript-eslint/no-empty-function
- invalidateAsync: async () => {},
- }),
- createWidgetOptionsChannel: () => ({}),
-}));
-
vi.mock("@homarr/docker/env", () => ({
env: {
ENABLE_DOCKER: true,
@@ -67,12 +51,10 @@ const validInputs: {
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
} = {
getContainers: undefined,
- subscribeContainers: undefined,
startAll: { ids: ["1"] },
stopAll: { ids: ["1"] },
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
- invalidate: undefined,
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
};
diff --git a/packages/api/src/router/test/integration/integration-test-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-connection.spec.ts
index f2519c533..0da8356c4 100644
--- a/packages/api/src/router/test/integration/integration-test-connection.spec.ts
+++ b/packages/api/src/router/test/integration/integration-test-connection.spec.ts
@@ -270,78 +270,4 @@ describe("testConnectionAsync should run test connection of integration", () =>
externalUrl: null,
});
});
-
- test("with input of existing github app", async () => {
- // Arrange
- const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
- const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
- factorySpy.mockReturnValue(
- Promise.resolve({
- testConnectionAsync: async () => await Promise.resolve({ success: true }),
- } as homarrIntegrations.PiHoleIntegrationV6),
- );
- optionsSpy.mockReturnValue([[], ["githubAppId", "githubInstallationId", "privateKey"]]);
-
- const integration = {
- id: "new",
- name: "GitHub",
- url: "https://api.github.com",
- kind: "github" as const,
- secrets: [
- {
- kind: "githubAppId" as const,
- value: "345",
- },
- {
- kind: "githubInstallationId" as const,
- value: "456",
- },
- {
- kind: "privateKey" as const,
- value: null,
- },
- ],
- };
-
- const dbSecrets = [
- {
- kind: "githubAppId" as const,
- value: "123.encrypted" as const,
- },
- {
- kind: "githubInstallationId" as const,
- value: "234.encrypted" as const,
- },
- {
- kind: "privateKey" as const,
- value: "privateKey.encrypted" as const,
- },
- ];
-
- // Act
- await testConnectionAsync(integration, dbSecrets);
-
- // Assert
- expect(factorySpy).toHaveBeenCalledWith({
- id: "new",
- name: "GitHub",
- url: "https://api.github.com",
- kind: "github" as const,
- decryptedSecrets: [
- expect.objectContaining({
- kind: "githubAppId",
- value: "345",
- }),
- expect.objectContaining({
- kind: "githubInstallationId",
- value: "456",
- }),
- expect.objectContaining({
- kind: "privateKey",
- value: "privateKey",
- }),
- ],
- externalUrl: null,
- });
- });
});
diff --git a/packages/api/src/router/update-checker.ts b/packages/api/src/router/update-checker.ts
index 8957be5f2..8cb6c097e 100644
--- a/packages/api/src/router/update-checker.ts
+++ b/packages/api/src/router/update-checker.ts
@@ -9,7 +9,7 @@ export const updateCheckerRouter = createTRPCRouter({
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
try {
const handler = updateCheckerRequestHandler.handler({});
- const data = await handler.getCachedOrUpdatedDataAsync({});
+ const data = await handler.getDataAsync();
return data.data.availableUpdates;
} catch (error) {
logger.error(new Error("Failed to get available updates", { cause: error }));
diff --git a/packages/api/src/router/widgets/anchor-notes.ts b/packages/api/src/router/widgets/anchor-notes.ts
index 7e5b7043c..d431c0de9 100644
--- a/packages/api/src/router/widgets/anchor-notes.ts
+++ b/packages/api/src/router/widgets/anchor-notes.ts
@@ -45,9 +45,7 @@ export const anchorNotesRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
- const { data } = await handler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ const { data } = await handler.getDataAsync();
return data;
}),
@@ -57,9 +55,7 @@ export const anchorNotesRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
- const { data } = await handler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ const { data } = await handler.getDataAsync();
return data;
}),
@@ -79,9 +75,6 @@ export const anchorNotesRouter = createTRPCRouter({
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
- const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
- await noteHandler.invalidateAsync();
-
return updatedNote;
} catch (error) {
if (error instanceof ResponseError && error.statusCode === 400) {
diff --git a/packages/api/src/router/widgets/app.ts b/packages/api/src/router/widgets/app.ts
index d07241725..92e1298da 100644
--- a/packages/api/src/router/widgets/app.ts
+++ b/packages/api/src/router/widgets/app.ts
@@ -1,10 +1,8 @@
import { TRPCError } from "@trpc/server";
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { sendPingRequestAsync } from "@homarr/ping";
-import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { AppRepository } from "../app";
@@ -45,55 +43,4 @@ export const appRouter = createTRPCRouter({
...pingResult,
};
}),
- updatedPing: publicProcedure
- .input(
- z.object({
- id: z.string(),
- }),
- )
- .subscription(async ({ ctx, input }) => {
- const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
- if (boardSettings.forceDisableStatus) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Ping functionality is disabled by server settings",
- });
- }
-
- const repository = new AppRepository(ctx.db, ctx.session?.user ?? null);
- const app = await repository.getByIdAsync(input.id);
-
- if (!app) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "App not found",
- });
- }
-
- const pingUrl = app.pingUrl ?? app.href;
-
- if (!pingUrl) {
- throw new TRPCError({
- code: "CONFLICT",
- message: "No URL to ping configured for this app",
- });
- }
-
- await pingUrlChannel.addAsync(pingUrl);
-
- return observable<{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }>(
- (emit) => {
- const unsubscribe = pingChannel.subscribe((message) => {
- // Only emit if same url
- if (message.url !== pingUrl) return;
- emit.next(message);
- });
-
- return () => {
- unsubscribe();
- void pingUrlChannel.removeAsync(pingUrl);
- };
- },
- );
- }),
});
diff --git a/packages/api/src/router/widgets/archive-team-warrior.ts b/packages/api/src/router/widgets/archive-team-warrior.ts
index 0824c165a..d71b7546a 100644
--- a/packages/api/src/router/widgets/archive-team-warrior.ts
+++ b/packages/api/src/router/widgets/archive-team-warrior.ts
@@ -1,6 +1,3 @@
-import { observable } from "@trpc/server/observable";
-
-import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -11,29 +8,11 @@ export const archiveTeamWarriorRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
- const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data, timestamp } = await handler.getDataAsync();
return {
status: data,
updatedAt: timestamp,
};
}),
-
- subscribeStatus: publicProcedure
- .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
- .subscription(({ ctx }) => {
- return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => {
- const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
- const unsubscribe = handler.subscribe((status) => {
- emit.next({
- status,
- updatedAt: new Date(),
- });
- });
-
- return () => {
- unsubscribe();
- };
- });
- }),
});
diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts
index d43876583..14fd7d49c 100644
--- a/packages/api/src/router/widgets/audio-stats.ts
+++ b/packages/api/src/router/widgets/audio-stats.ts
@@ -13,14 +13,14 @@ export const audioStatsRouter = createTRPCRouter({
const fetchByKind = {
navidrome: async () => {
const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {});
- return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
+ return (await handler.getDataAsync()).data;
},
audiobookshelf: async () => {
const handler = audiobookshelfRequestHandler.handler(
{ ...ctx.integration, kind: "audiobookshelf" as const },
{},
);
- return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
+ return (await handler.getDataAsync()).data;
},
} as const;
diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts
index cbb453d03..797de8609 100644
--- a/packages/api/src/router/widgets/beszel.ts
+++ b/packages/api/src/router/widgets/beszel.ts
@@ -1,16 +1,12 @@
import { TRPCError } from "@trpc/server";
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
-import { createIntegrationAsync } from "@homarr/integrations";
-import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
-import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -33,9 +29,7 @@ export const beszelRouter = createTRPCRouter({
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
@@ -71,30 +65,6 @@ export const beszelRouter = createTRPCRouter({
return results;
}),
- subscribeSystems: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
- .subscription(({ ctx }) => {
- return observable<{
- integrationId: string;
- systems: BeszelSystemRow[];
- timestamp: Date;
- }>((emit) => {
- const unsubscribes = ctx.integrations.map((integration) => {
- const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
- return innerHandler.subscribe((systems) => {
- emit.next({
- integrationId: integration.id,
- systems,
- timestamp: new Date(),
- });
- });
- });
- return () => {
- unsubscribes.forEach((fn) => fn());
- };
- });
- }),
-
getAlerts: publicProcedure
.meta({
mcp: {
@@ -125,13 +95,17 @@ export const beszelRouter = createTRPCRouter({
maxHistoryItems: input.maxHistoryItems,
});
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
- const [alertsResult, systemsResult] = await Promise.all([
- alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
- systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
+ const [alertsSettled, systemsSettled] = await Promise.allSettled([
+ alertsHandler.getDataAsync(),
+ systemsHandler.getDataAsync(),
]);
+ if (alertsSettled.status === "rejected") throw alertsSettled.reason;
+ const alertsResult = alertsSettled.value;
const systemNameMap: Record = {};
- for (const system of systemsResult.data) {
- systemNameMap[system.id] = system.name;
+ if (systemsSettled.status === "fulfilled") {
+ for (const system of systemsSettled.value.data) {
+ systemNameMap[system.id] = system.name;
+ }
}
return {
integrationId: integration.id,
@@ -170,39 +144,6 @@ export const beszelRouter = createTRPCRouter({
return results;
}),
- subscribeAlerts: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
- .input(
- z.object({
- includeHistory: z.boolean().default(true),
- maxHistoryItems: z.number().min(1).max(100).default(10),
- }),
- )
- .subscription(({ ctx, input }) => {
- return observable<{
- integrationId: string;
- alerts: BeszelAlertsData;
- timestamp: Date;
- }>((emit) => {
- const unsubscribes = ctx.integrations.map((integration) => {
- const innerHandler = beszelAlertsRequestHandler.handler(integration, {
- includeHistory: input.includeHistory,
- maxHistoryItems: input.maxHistoryItems,
- });
- return innerHandler.subscribe((data) => {
- emit.next({
- integrationId: integration.id,
- alerts: data,
- timestamp: new Date(),
- });
- });
- });
- return () => {
- unsubscribes.forEach((fn) => fn());
- };
- });
- }),
-
getSystemStats: publicProcedure
.meta({
mcp: {
@@ -237,7 +178,7 @@ export const beszelRouter = createTRPCRouter({
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data, timestamp } = await innerHandler.getDataAsync();
logger.debug("getSystemStats completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
@@ -260,61 +201,4 @@ export const beszelRouter = createTRPCRouter({
};
}
}),
-
- subscribeSystemStats: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
- .input(
- z.object({
- systemId: z.string(),
- }),
- )
- .subscription(({ ctx, input }) => {
- return observable<{
- stats: BeszelSystemStatsRecord;
- containerStats: BeszelContainerStatsRecord | null;
- }>((emit) => {
- const integration = ctx.integrations[0];
- if (!integration) {
- emit.error(new Error("No Beszel integration configured"));
- return () => {};
- }
- logger.debug("subscribeSystemStats opened", {
- userId: ctx.session?.user?.id,
- integrationId: integration.id,
- systemId: input.systemId,
- });
-
- const abortController = new AbortController();
-
- void (async () => {
- try {
- const instance = await createIntegrationAsync(integration);
- await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
- } catch (error) {
- if (!abortController.signal.aborted) {
- logger.warn("subscribeSystemStats error", {
- userId: ctx.session?.user?.id,
- integrationId: integration.id,
- systemId: input.systemId,
- error,
- });
- if (error instanceof Error) {
- emit.error(error);
- } else {
- emit.error(new Error(String(error)));
- }
- }
- }
- })();
-
- return () => {
- abortController.abort();
- logger.debug("subscribeSystemStats closed", {
- userId: ctx.session?.user?.id,
- integrationId: integration.id,
- systemId: input.systemId,
- });
- };
- });
- }),
});
diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts
index 50ba71b3e..729e82634 100644
--- a/packages/api/src/router/widgets/calendar.ts
+++ b/packages/api/src/router/widgets/calendar.ts
@@ -1,13 +1,8 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
-import type { Modify } from "@homarr/common/types";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
-import type { Integration } from "@homarr/db/schema";
-import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type { CalendarEvent } from "@homarr/integrations/types";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
@@ -39,9 +34,7 @@ export const calendarRouter = createTRPCRouter({
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ const { data } = await innerHandler.getDataAsync();
return {
events: data,
@@ -72,39 +65,4 @@ export const calendarRouter = createTRPCRouter({
return [];
});
}),
- subscribeToEvents: publicProcedure
- .input(
- z.object({
- year: z.number(),
- month: z.number(),
- releaseType: z.array(z.enum(radarrReleaseTypes)),
- showUnmonitored: z.boolean(),
- }),
- )
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
- .subscription(({ ctx, input }) => {
- return observable<{
- integration: Modify }>;
- events: CalendarEvent[];
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const { integrationIds: _integrationIds, ...handlerInput } = input;
- const innerHandler = calendarMonthRequestHandler.handler(integrationWithSecrets, handlerInput);
- const unsubscribe = innerHandler.subscribe((events) => {
- emit.next({
- integration,
- events,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
- });
- }),
});
diff --git a/packages/api/src/router/widgets/coolify.ts b/packages/api/src/router/widgets/coolify.ts
index 8bdaa6f3e..9476039d9 100644
--- a/packages/api/src/router/widgets/coolify.ts
+++ b/packages/api/src/router/widgets/coolify.ts
@@ -1,49 +1,23 @@
-import { observable } from "@trpc/server/observable";
-
-import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = coolifyRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = coolifyRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- instanceInfo: data,
- updatedAt: timestamp,
- };
- }),
- );
-
- return results;
- }),
- subscribeInstancesInfo: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "coolify"))
- .subscription(({ ctx }) => {
- return observable<{ integrationId: string; instanceInfo: CoolifyInstanceInfo; timestamp: Date }>((emit) => {
- const unsubscribes = ctx.integrations.map((integration) => {
- const innerHandler = coolifyRequestHandler.handler(integration, {});
- return innerHandler.subscribe((instanceInfo) => {
- emit.next({
- integrationId: integration.id,
- instanceInfo,
- timestamp: new Date(),
- });
- });
- });
-
- return () => {
- unsubscribes.forEach((unsubscribe) => unsubscribe());
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ instanceInfo: data,
+ updatedAt: timestamp,
};
});
}),
diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts
index 93162a1e3..32f15da98 100644
--- a/packages/api/src/router/widgets/dns-hole.ts
+++ b/packages/api/src/router/widgets/dns-hole.ts
@@ -1,15 +1,11 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
-import type { Modify } from "@homarr/common/types";
-import type { Integration } from "@homarr/db/schema";
-import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
-import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({
@@ -23,50 +19,11 @@ export const dnsHoleRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = dnsHoleRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
-
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- summary: data,
- };
- }),
- );
- return results;
- }),
-
- subscribeToSummary: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
- .subscription(({ ctx }) => {
- return observable<{
- integration: Modify }>;
- summary: DnsHoleSummary;
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((summary) => {
- emit.next({
- integration,
- summary,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
+ return {
+ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
+ summary: data,
};
});
}),
@@ -83,12 +40,6 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
-
- const innerHandler = dnsHoleRequestHandler.handler(integration, {});
- // We need to wait for the integration to be enabled before invalidating the cache
- await new Promise((resolve) => {
- setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
- });
}),
disable: protectedProcedure
@@ -108,11 +59,5 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
-
- const innerHandler = dnsHoleRequestHandler.handler(integration, {});
- // We need to wait for the integration to be disabled before invalidating the cache
- await new Promise((resolve) => {
- setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
- });
}),
});
diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts
index 682decdc4..50aee350e 100644
--- a/packages/api/src/router/widgets/downloads.ts
+++ b/packages/api/src/router/widgets/downloads.ts
@@ -1,16 +1,12 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
-import type { Modify } from "@homarr/common/types";
-import type { Integration } from "@homarr/db/schema";
-import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
@@ -28,52 +24,12 @@ export const downloadsRouter = createTRPCRouter({
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
- return await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
-
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
-
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- data,
- };
- }),
- );
- }),
- subscribeToJobsAndStatuses: publicProcedure
- .concat(createDownloadClientIntegrationMiddleware("query"))
- .input(z.object({ limitPerIntegration: z.number().default(50) }))
- .subscription(({ ctx, input }) => {
- return observable<{
- integration: Modify }>;
- data: DownloadClientJobsAndStatus;
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {
- limit: input.limitPerIntegration,
- });
- const unsubscribe = innerHandler.subscribe((data) => {
- emit.next({
- integration,
- data,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
+ const { data, timestamp } = await innerHandler.getDataAsync();
+ return {
+ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
+ data,
};
});
}),
diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts
index 2d582cb1f..08951da7c 100644
--- a/packages/api/src/router/widgets/firewall.ts
+++ b/packages/api/src/router/widgets/firewall.ts
@@ -1,15 +1,4 @@
-import { observable } from "@trpc/server/observable";
-
-import type { Modify } from "@homarr/common/types";
-import type { Integration } from "@homarr/db/schema";
-import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type {
- FirewallCpuSummary,
- FirewallInterfacesSummary,
- FirewallMemorySummary,
- FirewallVersionSummary,
-} from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
@@ -18,198 +7,31 @@ import {
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
+
+const queryFirewall = <
+ THandler extends {
+ handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> };
+ },
+>(
+ handler: THandler,
+) =>
+ publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
+ settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
+ return {
+ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
+ summary: data as Awaited["getDataAsync"]>>["data"],
+ };
+ }),
+ );
+
export const firewallRouter = createTRPCRouter({
- getFirewallCpuStatus: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
- .query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = firewallCpuRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
-
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- summary: data,
- };
- }),
- );
- return results;
- }),
- subscribeFirewallCpuStatus: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
- .subscription(({ ctx }) => {
- return observable<{
- integration: Modify }>;
- summary: FirewallCpuSummary;
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = firewallCpuRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((summary) => {
- emit.next({
- integration,
- summary,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
- });
- }),
-
- getFirewallInterfacesStatus: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
- .query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = firewallInterfacesRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
-
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- summary: data,
- };
- }),
- );
- return results;
- }),
- subscribeFirewallInterfacesStatus: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
- .subscription(({ ctx }) => {
- return observable<{
- integration: Modify }>;
- summary: FirewallInterfacesSummary[];
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = firewallInterfacesRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((summary) => {
- emit.next({
- integration,
- summary,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
- });
- }),
-
- getFirewallVersionStatus: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
- .query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = firewallVersionRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
-
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- summary: data,
- };
- }),
- );
- return results;
- }),
- subscribeFirewallVersionStatus: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
- .subscription(({ ctx }) => {
- return observable<{
- integration: Modify }>;
- summary: FirewallVersionSummary;
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = firewallVersionRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((summary) => {
- emit.next({
- integration,
- summary,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
- });
- }),
-
- getFirewallMemoryStatus: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
- .query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = firewallMemoryRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
-
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- summary: data,
- };
- }),
- );
- return results;
- }),
- subscribeFirewallMemoryStatus: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
- .subscription(({ ctx }) => {
- return observable<{
- integration: Modify }>;
- summary: FirewallMemorySummary;
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = firewallMemoryRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((summary) => {
- emit.next({
- integration,
- summary,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
- });
- }),
+ getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
+ getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
+ getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
+ getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
});
diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts
index 03856f5a0..de49a1083 100644
--- a/packages/api/src/router/widgets/health-monitoring.ts
+++ b/packages/api/src/router/widgets/health-monitoring.ts
@@ -1,10 +1,7 @@
-import { observable } from "@trpc/server/observable";
-
-import type { SystemHealthMonitoring } from "@homarr/integrations";
-import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
@@ -20,48 +17,13 @@ export const healthMonitoringRouter = createTRPCRouter({
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
.query(async ({ ctx }) => {
- return await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = systemInfoRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
-
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- healthInfo: data,
- updatedAt: timestamp,
- };
- }),
- );
- }),
- subscribeSystemHealthStatus: publicProcedure
- .concat(
- createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
- )
- .subscription(({ ctx }) => {
- return observable<{
- integrationId: string;
- healthInfo: SystemHealthMonitoring;
- timestamp: Date;
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integration of ctx.integrations) {
- const innerHandler = systemInfoRequestHandler.handler(integration, {});
- const unsubscribe = innerHandler.subscribe((healthInfo) => {
- emit.next({
- integrationId: integration.id,
- healthInfo,
- timestamp: new Date(),
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ healthInfo: data,
+ updatedAt: timestamp,
};
});
}),
@@ -76,24 +38,7 @@ export const healthMonitoringRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ const { data } = await innerHandler.getDataAsync();
return data;
}),
- subscribeClusterHealthStatus: publicProcedure
- .concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
- .subscription(({ ctx }) => {
- return observable((emit) => {
- const unsubscribes: (() => void)[] = [];
- const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
- const unsubscribe = innerHandler.subscribe((healthInfo) => {
- emit.next(healthInfo);
- });
- unsubscribes.push(unsubscribe);
- return () => {
- unsubscribe();
- };
- });
- }),
});
diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts
index f1428a80c..7e8c54d49 100644
--- a/packages/api/src/router/widgets/immich.ts
+++ b/packages/api/src/router/widgets/immich.ts
@@ -17,7 +17,7 @@ const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
- const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -30,13 +30,13 @@ export const immichRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
- const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
- const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const data = await innerHandler.getDataAsync();
return data.data;
}),
});
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts
index 70c1aa997..046f422fe 100644
--- a/packages/api/src/router/widgets/index.ts
+++ b/packages/api/src/router/widgets/index.ts
@@ -36,6 +36,8 @@ export const widgetRouter = createTRPCRouter({
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
+ vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
+ secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts
index cb8b88262..54a949dab 100644
--- a/packages/api/src/router/widgets/indexer-manager.ts
+++ b/packages/api/src/router/widgets/indexer-manager.ts
@@ -1,13 +1,12 @@
import { TRPCError } from "@trpc/server";
-import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
-import type { Indexer } from "@homarr/integrations/types";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -17,39 +16,13 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = indexerManagerRequestHandler.handler(integration, {});
- const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = indexerManagerRequestHandler.handler(integration, {});
+ const { data: indexers } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- indexers,
- };
- }),
- );
- return results;
- }),
-
- subscribeIndexersStatus: publicProcedure
- .concat(createIndexerManagerIntegrationMiddleware("query"))
- .subscription(({ ctx }) => {
- return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((indexers) => {
- emit.next({
- integrationId: integrationWithSecrets.id,
- indexers,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
+ return {
+ integrationId: integration.id,
+ indexers,
};
});
}),
diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts
index fddc174cc..f05a46142 100644
--- a/packages/api/src/router/widgets/media-release.ts
+++ b/packages/api/src/router/widgets/media-release.ts
@@ -1,35 +1,28 @@
-import { observable } from "@trpc/server/observable";
-
-import type { Modify } from "@homarr/common/types";
-import type { Integration } from "@homarr/db/schema";
-import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type { MediaRelease } from "@homarr/integrations/types";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- releases: data,
- };
- }),
- );
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ releases: data,
+ };
+ });
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
@@ -37,31 +30,4 @@ export const mediaReleaseRouter = createTRPCRouter({
})),
);
}),
-
- subscribeToReleases: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
- .subscription(({ ctx }) => {
- return observable<{
- integration: Modify }>;
- releases: MediaRelease[];
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = mediaReleaseRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((releases) => {
- emit.next({
- integration,
- releases,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
- });
- }),
});
diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts
index 715ae1327..99c4906e3 100644
--- a/packages/api/src/router/widgets/media-requests.ts
+++ b/packages/api/src/router/widgets/media-requests.ts
@@ -1,14 +1,13 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
-import type { MediaRequest } from "@homarr/integrations/types";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const mediaRequestsRouter = createTRPCRouter({
@@ -22,22 +21,10 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- },
- data,
- };
- }),
- );
+ const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
+ return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
+ });
return results
.flatMap(({ data, integration }) =>
data.map((request) => ({
@@ -56,32 +43,6 @@ export const mediaRequestsRouter = createTRPCRouter({
);
});
}),
- subscribeToLatestRequests: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
- .subscription(({ ctx }) => {
- return observable<{
- integrationId: string;
- requests: MediaRequest[];
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = mediaRequestListRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((requests) => {
- emit.next({
- integrationId: integration.id,
- requests,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
- });
- }),
getStats: publicProcedure
.meta({
mcp: {
@@ -92,22 +53,10 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- },
- data,
- };
- }),
- );
+ const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
+ return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
+ });
return {
stats: results.flatMap((result) => result.data.stats),
users: results
@@ -139,7 +88,6 @@ export const mediaRequestsRouter = createTRPCRouter({
)
.mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = await createIntegrationAsync(integration);
- const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
const answerActions = {
approve: (id: number) => integrationInstance.approveRequestAsync(id),
@@ -147,6 +95,5 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
- await innerHandler.invalidateAsync();
}),
});
diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts
index de61fef75..3e3e64a4f 100644
--- a/packages/api/src/router/widgets/media-server.ts
+++ b/packages/api/src/router/widgets/media-server.ts
@@ -1,12 +1,11 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type { StreamSession } from "@homarr/integrations";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -24,46 +23,11 @@ export const mediaServerRouter = createTRPCRouter({
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
- return await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = mediaServerRequestHandler.handler(integration, {
- showOnlyPlaying: input.showOnlyPlaying,
- });
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
- return {
- integrationId: integration.id,
- integrationKind: integration.kind,
- sessions: data,
- };
- }),
- );
- }),
- subscribeToCurrentStreams: publicProcedure
- .concat(createMediaServerIntegrationMiddleware("query"))
- .input(z.object({ showOnlyPlaying: z.boolean() }))
- .subscription(({ ctx, input }) => {
- return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integration of ctx.integrations) {
- const innerHandler = mediaServerRequestHandler.handler(integration, {
- showOnlyPlaying: input.showOnlyPlaying,
- });
-
- const unsubscribe = innerHandler.subscribe((sessions) => {
- emit.next({
- integrationId: integration.id,
- data: sessions,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data } = await mediaServerRequestHandler
+ .handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
+ .getDataAsync();
+ return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
});
}),
});
diff --git a/packages/api/src/router/widgets/media-transcoding.ts b/packages/api/src/router/widgets/media-transcoding.ts
index b02f0a714..88bd91704 100644
--- a/packages/api/src/router/widgets/media-transcoding.ts
+++ b/packages/api/src/router/widgets/media-transcoding.ts
@@ -1,7 +1,4 @@
-import { observable } from "@trpc/server/observable";
-
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type { MediaTranscoding } from "@homarr/request-handler/media-transcoding";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { paginatedSchema } from "@homarr/validation/common";
@@ -21,26 +18,11 @@ export const mediaTranscodingRouter = createTRPCRouter({
pageOffset: (input.page - 1) * input.pageSize,
pageSize: input.pageSize,
});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getDataAsync();
return {
integrationId: ctx.integration.id,
data,
};
}),
- subscribeData: publicProcedure
- .concat(createIndexerManagerIntegrationMiddleware("query"))
- .input(paginatedSchema.pick({ page: true, pageSize: true }))
- .subscription(({ ctx, input }) => {
- return observable<{ integrationId: string; data: MediaTranscoding }>((emit) => {
- const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
- pageOffset: (input.page - 1) * input.pageSize,
- pageSize: input.pageSize,
- });
- const unsubscribe = innerHandler.subscribe((data) => {
- emit.next({ integrationId: input.integrationId, data });
- });
- return unsubscribe;
- });
- }),
});
diff --git a/packages/api/src/router/widgets/minecraft.ts b/packages/api/src/router/widgets/minecraft.ts
index 7f646c3bc..9cc5e25d7 100644
--- a/packages/api/src/router/widgets/minecraft.ts
+++ b/packages/api/src/router/widgets/minecraft.ts
@@ -1,7 +1,5 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
-import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status";
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -16,21 +14,6 @@ export const minecraftRouter = createTRPCRouter({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
- return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
- }),
- subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
- return observable((emit) => {
- const innerHandler = minecraftServerStatusRequestHandler.handler({
- isBedrockServer: input.isBedrockServer,
- domain: input.domain,
- });
- const unsubscribe = innerHandler.subscribe((data) => {
- emit.next(data);
- });
-
- return () => {
- unsubscribe();
- };
- });
+ return await innerHandler.getDataAsync();
}),
});
diff --git a/packages/api/src/router/widgets/network-controller.ts b/packages/api/src/router/widgets/network-controller.ts
index 053bd0dcb..61257d1ec 100644
--- a/packages/api/src/router/widgets/network-controller.ts
+++ b/packages/api/src/router/widgets/network-controller.ts
@@ -1,61 +1,26 @@
-import { observable } from "@trpc/server/observable";
-
-import type { Modify } from "@homarr/common/types";
-import type { Integration } from "@homarr/db/schema";
-import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = networkControllerRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = networkControllerRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- },
- summary: data,
- updatedAt: timestamp,
- };
- }),
- );
- return results;
- }),
-
- subscribeToSummary: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
- .subscription(({ ctx }) => {
- return observable<{
- integration: Modify }>;
- summary: NetworkControllerSummary;
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = networkControllerRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((summary) => {
- emit.next({
- integration,
- summary,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ },
+ summary: data,
+ updatedAt: timestamp,
};
});
}),
diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts
index 89ec95165..bae8ba9fd 100644
--- a/packages/api/src/router/widgets/notifications.ts
+++ b/packages/api/src/router/widgets/notifications.ts
@@ -1,14 +1,9 @@
-import { observable } from "@trpc/server/observable";
-
-import type { Modify } from "@homarr/common/types";
-import type { Integration } from "@homarr/db/schema";
-import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type { Notification } from "@homarr/integrations";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
@@ -18,46 +13,18 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
- return await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = notificationsRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = notificationsRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- data,
- };
- }),
- );
- }),
- subscribeNotifications: publicProcedure
- .unstable_concat(createNotificationsIntegrationMiddleware("query"))
- .subscription(({ ctx }) => {
- return observable<{
- integration: Modify }>;
- data: Notification[];
- }>((emit) => {
- const unsubscribes: (() => void)[] = [];
- for (const integrationWithSecrets of ctx.integrations) {
- const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
- const innerHandler = notificationsRequestHandler.handler(integrationWithSecrets, {});
- const unsubscribe = innerHandler.subscribe((data) => {
- emit.next({
- integration,
- data,
- });
- });
- unsubscribes.push(unsubscribe);
- }
- return () => {
- unsubscribes.forEach((unsubscribe) => {
- unsubscribe();
- });
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ data,
};
});
}),
diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts
index 0fb81e72a..2a1bf40de 100644
--- a/packages/api/src/router/widgets/paperless-ngx.ts
+++ b/packages/api/src/router/widgets/paperless-ngx.ts
@@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
- const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const data = await innerHandler.getDataAsync();
return data.data;
}),
});
diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts
index 3d355b26c..b58fecd19 100644
--- a/packages/api/src/router/widgets/releases.ts
+++ b/packages/api/src/router/widgets/releases.ts
@@ -1,11 +1,15 @@
+import SuperJSON from "superjson";
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
-import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { decryptSecret } from "@homarr/common/server";
+import { eq } from "@homarr/db";
+import { boards, items, widgetSecrets } from "@homarr/db/schema";
+import { releaseProviderKinds } from "@homarr/definitions";
import { releasesRequestHandler } from "@homarr/request-handler/releases";
-import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+import { throwIfActionForbiddenAsync } from "../board/board-access";
const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => {
if (!versionFilter) return undefined;
@@ -25,37 +29,83 @@ const releaseVersionFilterSchema = z.object({
export const releasesRouter = createTRPCRouter({
getLatest: publicProcedure
- .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
.input(
z.object({
+ itemId: z.string().optional(),
repositories: z.array(
z.object({
- id: z.string(),
+ id: z.string().optional(),
+ provider: z.enum(releaseProviderKinds),
identifier: z.string(),
versionFilter: releaseVersionFilterSchema.optional(),
+ providerUrl: z.string().url().optional(),
}),
),
}),
)
.query(async ({ ctx, input }) => {
+ const tokensByProvider = new Map();
+ let allowedRepoIds: Set | null = null;
+
+ if (input.itemId) {
+ try {
+ const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) });
+ if (item && item.kind === "releases") {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
+
+ const options = SuperJSON.parse>(item.options);
+ const repos = options.repositories as Array<{ id?: string }> | undefined;
+ if (repos) {
+ allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
+ }
+
+ const secrets = await ctx.db.query.widgetSecrets.findMany({
+ where: eq(widgetSecrets.itemId, input.itemId),
+ });
+ for (const secret of secrets) {
+ try {
+ tokensByProvider.set(secret.kind, decryptSecret(secret.value));
+ } catch {
+ // Skip corrupt secrets
+ }
+ }
+ }
+ } catch {
+ // Access denied or table not yet migrated -- proceed without tokens
+ }
+ }
+
return await Promise.all(
input.repositories.map(async (repository) => {
- const response = await releasesRequestHandler
- .handler(ctx.integration, {
- id: repository.id,
- identifier: repository.identifier,
- versionRegex: formatVersionFilterRegex(repository.versionFilter),
- })
- .getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ const repositoryId = repository.id ?? repository.identifier;
+ try {
+ const useToken = allowedRepoIds === null || allowedRepoIds.has(repositoryId);
+ const response = await releasesRequestHandler
+ .handler({
+ id: repositoryId,
+ provider: repository.provider,
+ identifier: repository.identifier,
+ versionRegex: formatVersionFilterRegex(repository.versionFilter),
+ providerUrl: repository.providerUrl,
+ token: useToken ? tokensByProvider.get(repository.provider) : undefined,
+ })
+ .getDataAsync();
- return {
- id: repository.id,
- integration: { name: ctx.integration.name, kind: ctx.integration.kind },
- timestamp: response.timestamp,
- ...response.data,
- };
+ return {
+ id: repositoryId,
+ provider: repository.provider,
+ timestamp: response.timestamp,
+ ...response.data,
+ };
+ } catch (error) {
+ return {
+ id: repositoryId,
+ provider: repository.provider,
+ timestamp: new Date(),
+ success: false as const,
+ error: { code: "unexpected" as const, message: String(error) },
+ };
+ }
}),
);
}),
diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts
index ec9c223e0..5e43efe3e 100644
--- a/packages/api/src/router/widgets/rssFeed.ts
+++ b/packages/api/src/router/widgets/rssFeed.ts
@@ -1,4 +1,3 @@
-import { observable } from "@trpc/server/observable";
import SuperJSON from "superjson";
import { z } from "zod/v4";
@@ -6,12 +5,14 @@ import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { items, users } from "@homarr/db/schema";
-import type { ExtendedFeedEntry } from "@homarr/request-handler/rss-feeds";
+import { createLogger } from "@homarr/core/infrastructure/logs";
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import type { WidgetComponentProps } from "../../../../widgets/src";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+const logger = createLogger({ module: "rssFeed" });
+
const feedsInput = z.object({
urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(),
@@ -23,41 +24,28 @@ export const rssFeedRouter = createTRPCRouter({
? input.urls
: await restrictUrlsAsync(ctx.db, input.urls);
- const rssFeeds = await Promise.all(
+ const settled = await Promise.allSettled(
urls.map(async (url) => {
const innerHandler = rssFeedsRequestHandler.handler({
url,
count: input.maximumAmountPosts,
});
- return await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ return await innerHandler.getDataAsync();
}),
);
- return rssFeeds
- .flatMap((rssFeed) => rssFeed.data.entries)
- .slice(0, input.maximumAmountPosts)
- .sort((entryA, entryB) => {
+ return settled
+ .flatMap((result, index) => {
+ if (result.status === "fulfilled") return result.value.data.entries;
+ logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
+ return [];
+ })
+ .toSorted((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
- });
- }),
- subscribeFeeds: publicProcedure.input(feedsInput).subscription(({ input }) => {
- // It is not necessary to check access to all feeds here, as the client will only subscribe to feeds which already exist
- return observable<{ url: string; entries: ExtendedFeedEntry[] }>((emit) => {
- const unsubscribes = input.urls.map((url) => {
- const innerHandler = rssFeedsRequestHandler.handler({ url, count: input.maximumAmountPosts });
- return innerHandler.subscribe((data) => {
- emit.next({ url, entries: data.entries });
- });
- });
-
- return () => {
- unsubscribes.forEach((unsubscribe) => unsubscribe());
- };
- });
+ })
+ .slice(0, input.maximumAmountPosts);
}),
});
diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts
index dec3770a7..c37468752 100644
--- a/packages/api/src/router/widgets/smart-home.ts
+++ b/packages/api/src/router/widgets/smart-home.ts
@@ -1,4 +1,3 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
@@ -25,31 +24,9 @@ export const smartHomeRouter = createTRPCRouter({
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: false,
- });
+ const { data } = await innerHandler.getDataAsync();
return data;
}),
- subscribeEntityState: publicProcedure
- .concat(createSmartHomeIntegrationMiddleware("query"))
- .input(z.object({ entityId: z.string() }))
- .subscription(({ input, ctx }) => {
- return observable<{
- entityId: string;
- state: string;
- }>((emit) => {
- const innerHandler = smartHomeEntityStateRequestHandler.handler(ctx.integration, {
- entityId: input.entityId,
- });
- const unsubscribe = innerHandler.subscribe((state) => {
- emit.next({ state, entityId: input.entityId });
- });
-
- return () => {
- unsubscribe();
- };
- });
- }),
switchEntity: protectedProcedure
.meta({
mcp: {
@@ -64,9 +41,6 @@ export const smartHomeRouter = createTRPCRouter({
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
- const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
- await innerHandler.invalidateAsync();
-
return success;
}),
executeAutomation: protectedProcedure
diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts
index be4245921..01e7fb58b 100644
--- a/packages/api/src/router/widgets/speedtest-tracker.ts
+++ b/packages/api/src/router/widgets/speedtest-tracker.ts
@@ -1,54 +1,23 @@
-import { observable } from "@trpc/server/observable";
-
-import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- dashboard: data,
- updatedAt: timestamp,
- };
- }),
- );
-
- return results;
- }),
-
- subscribeToDashboard: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
- .subscription(({ ctx }) => {
- return observable<{
- integrationId: string;
- dashboard: SpeedtestTrackerDashboardData;
- timestamp: Date;
- }>((emit) => {
- const unsubscribes = ctx.integrations.map((integration) => {
- const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
- return innerHandler.subscribe((dashboard) => {
- emit.next({
- integrationId: integration.id,
- dashboard,
- timestamp: new Date(),
- });
- });
- });
-
- return () => {
- unsubscribes.forEach((unsubscribe) => unsubscribe());
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: data,
+ updatedAt: timestamp,
};
});
}),
diff --git a/packages/api/src/router/widgets/stocks.ts b/packages/api/src/router/widgets/stocks.ts
index d7b4ed13a..7fc08005b 100644
--- a/packages/api/src/router/widgets/stocks.ts
+++ b/packages/api/src/router/widgets/stocks.ts
@@ -18,6 +18,6 @@ export const stockPriceRouter = createTRPCRouter({
timeRange: input.timeRange,
timeInterval: input.timeInterval,
});
- return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await innerHandler.getDataAsync();
}),
});
diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts
index 787be4c37..ef60ce320 100644
--- a/packages/api/src/router/widgets/timetable.ts
+++ b/packages/api/src/router/widgets/timetable.ts
@@ -1,43 +1,36 @@
import z from "zod";
-import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import { timetableOptionsSchema } from "@homarr/integrations/types";
import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
} from "@homarr/request-handler/timetable";
-import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+const baseUrlSchema = z.string().url();
+
export const timetableRouter = createTRPCRouter({
getTimetable: publicProcedure
- .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable")))
- .input(timetableOptionsSchema)
- .query(async ({ input, ctx }) => {
- const innerHandler = timetableGetTimetableRequestHandler.handler(ctx.integration, {
- stationId: input.stationId,
- limit: input.limit,
- });
-
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
-
+ .input(
+ z.object({
+ baseUrl: baseUrlSchema,
+ stationId: z.string(),
+ limit: z.number().int().min(1).max(100),
+ }),
+ )
+ .query(async ({ input }) => {
+ const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync();
return data;
}),
searchStations: publicProcedure
- .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable")))
.input(
z.object({
+ baseUrl: baseUrlSchema,
query: z.string(),
}),
)
- .query(async ({ input, ctx }) => {
- const innerHandler = timetableSearchStationsRequestHandler.handler(ctx.integration, {
- query: input.query,
- });
-
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
-
+ .query(async ({ input }) => {
+ const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
return data;
}),
});
diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts
index d46c80c66..5075c990b 100644
--- a/packages/api/src/router/widgets/tracearr.ts
+++ b/packages/api/src/router/widgets/tracearr.ts
@@ -1,48 +1,22 @@
-import { observable } from "@trpc/server/observable";
-
-import type { TracearrDashboardData } from "@homarr/integrations/types";
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = tracearrRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = tracearrRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- dashboard: data,
- updatedAt: timestamp,
- };
- }),
- );
-
- return results;
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: data,
+ updatedAt: timestamp,
+ };
+ });
}),
- subscribeToDashboard: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "tracearr"))
- .subscription(({ ctx }) => {
- return observable<{ integrationId: string; dashboard: TracearrDashboardData; timestamp: Date }>((emit) => {
- const unsubscribes = ctx.integrations.map((integration) => {
- const innerHandler = tracearrRequestHandler.handler(integration, {});
- return innerHandler.subscribe((dashboard) => {
- emit.next({
- integrationId: integration.id,
- dashboard,
- timestamp: new Date(),
- });
- });
- });
-
- return () => {
- unsubscribes.forEach((unsubscribe) => unsubscribe());
- };
- });
- }),
});
diff --git a/packages/api/src/router/widgets/umami.ts b/packages/api/src/router/widgets/umami.ts
index 2c240f4bb..3994f398e 100644
--- a/packages/api/src/router/widgets/umami.ts
+++ b/packages/api/src/router/widgets/umami.ts
@@ -1,4 +1,3 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,6 +13,7 @@ import {
} from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
@@ -39,26 +39,22 @@ export const umamiRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = umamiRequestHandler.handler(integration, {
- websiteId: input.websiteId,
- timeFrame: input.timeFrame,
- eventName: input.eventName,
- });
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = umamiRequestHandler.handler(integration, {
+ websiteId: input.websiteId,
+ timeFrame: input.timeFrame,
+ eventName: input.eventName,
+ });
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- visitorStats: data,
- updatedAt: timestamp,
- };
- }),
- );
-
- return results;
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ visitorStats: data,
+ updatedAt: timestamp,
+ };
+ });
}),
getEventNames: publicProcedure
@@ -67,7 +63,7 @@ export const umamiRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
logger.warn(
@@ -93,7 +89,7 @@ export const umamiRouter = createTRPCRouter({
timeFrame: input.timeFrame,
limit: input.limit,
});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
logger.warn(
@@ -123,7 +119,7 @@ export const umamiRouter = createTRPCRouter({
timeFrame: input.timeFrame,
limit: input.limit,
});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
logger.warn(
@@ -148,13 +144,13 @@ export const umamiRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
- const sortedNames = [...input.eventNames].sort();
+ const sortedNames = [...input.eventNames].toSorted();
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
logger.warn(
@@ -176,7 +172,7 @@ export const umamiRouter = createTRPCRouter({
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
logger.warn(
@@ -185,21 +181,4 @@ export const umamiRouter = createTRPCRouter({
return 0;
}
}),
-
- subscribeActiveVisitors: publicProcedure
- .input(z.object({ websiteId: z.string() }))
- .concat(createOneIntegrationMiddleware("query", "umami"))
- .subscription(({ ctx, input }) => {
- return observable((emit) => {
- const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
- websiteId: input.websiteId,
- });
- const unsubscribe = innerHandler.subscribe((count) => {
- emit.next(count);
- });
- return () => {
- unsubscribe();
- };
- });
- }),
});
diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts
index f1c27c434..16c6312dd 100644
--- a/packages/api/src/router/widgets/ups.ts
+++ b/packages/api/src/router/widgets/ups.ts
@@ -1,50 +1,24 @@
-import { observable } from "@trpc/server/observable";
-
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import type { UpsSummary } from "@homarr/integrations/types";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = upsSummariesRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = upsSummariesRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- summaries: data,
- updatedAt: timestamp,
- };
- }),
- );
-
- return results;
- }),
- subscribeSummaries: publicProcedure
- .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
- .subscription(({ ctx }) => {
- return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
- const unsubscribes = ctx.integrations.map((integration) => {
- const innerHandler = upsSummariesRequestHandler.handler(integration, {});
- return innerHandler.subscribe((summaries) => {
- emit.next({
- integrationId: integration.id,
- summaries,
- timestamp: new Date(),
- });
- });
- });
-
- return () => {
- unsubscribes.forEach((unsubscribe) => unsubscribe());
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ summaries: data,
+ updatedAt: timestamp,
};
});
}),
diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts
index eb28f9e7e..6df14090c 100644
--- a/packages/api/src/router/widgets/uptime-kuma.ts
+++ b/packages/api/src/router/widgets/uptime-kuma.ts
@@ -1,54 +1,23 @@
-import { observable } from "@trpc/server/observable";
-
-import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
- const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- dashboard: data,
- updatedAt: timestamp,
- };
- }),
- );
-
- return results;
- }),
-
- subscribeToDashboard: publicProcedure
- .concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
- .subscription(({ ctx }) => {
- return observable<{
- integrationId: string;
- dashboard: UptimeKumaDashboardData;
- timestamp: Date;
- }>((emit) => {
- const unsubscribes = ctx.integrations.map((integration) => {
- const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
- return innerHandler.subscribe((dashboard) => {
- emit.next({
- integrationId: integration.id,
- dashboard,
- timestamp: new Date(),
- });
- });
- });
-
- return () => {
- unsubscribes.forEach((unsubscribe) => unsubscribe());
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: data,
+ updatedAt: timestamp,
};
});
}),
diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts
new file mode 100644
index 000000000..407e4986f
--- /dev/null
+++ b/packages/api/src/router/widgets/vpn.ts
@@ -0,0 +1,25 @@
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const vpnRouter = createTRPCRouter({
+ getSummaries: publicProcedure
+ .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
+ .query(async ({ ctx }) => {
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ summary: data,
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts
index bb1077569..87ea6b48a 100644
--- a/packages/api/src/router/widgets/weather.ts
+++ b/packages/api/src/router/widgets/weather.ts
@@ -1,7 +1,5 @@
-import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
-import type { Weather } from "@homarr/request-handler/weather";
import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -14,16 +12,6 @@ const atLocationInput = z.object({
export const weatherRouter = createTRPCRouter({
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
const handler = weatherRequestHandler.handler(input);
- return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data);
- }),
- subscribeAtLocation: publicProcedure.input(atLocationInput).subscription(({ input }) => {
- return observable((emit) => {
- const handler = weatherRequestHandler.handler(input);
- const unsubscribe = handler.subscribe((data) => {
- emit.next(data);
- });
-
- return unsubscribe;
- });
+ return await handler.getDataAsync().then((result) => result.data);
}),
});
diff --git a/packages/api/src/router/widgets/widget-secrets.ts b/packages/api/src/router/widgets/widget-secrets.ts
new file mode 100644
index 000000000..75ae23cca
--- /dev/null
+++ b/packages/api/src/router/widgets/widget-secrets.ts
@@ -0,0 +1,97 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { encryptSecret } from "@homarr/common/server";
+import { and, eq, handleTransactionsAsync } from "@homarr/db";
+import { boards, items, widgetSecrets } from "@homarr/db/schema";
+import { releaseProviderKinds } from "@homarr/definitions";
+
+import { createTRPCRouter, protectedProcedure } from "../../trpc";
+import { throwIfActionForbiddenAsync } from "../board/board-access";
+
+const secretKindSchema = z.enum(releaseProviderKinds);
+
+const resolveItemWithBoardAccess = async (ctx: { db: any; session: any }, itemId: string) => {
+ const item = await ctx.db.query.items.findFirst({ where: eq(items.id, itemId) });
+ if (!item || item.kind !== "releases") throw new TRPCError({ code: "NOT_FOUND" });
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify");
+ return item;
+};
+
+export const widgetSecretsRouter = createTRPCRouter({
+ setSecret: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Set an authentication token for a release provider on a specific releases widget. Requires modify permission on the widget's board. Use widget_secrets_getConfiguredKinds to check existing tokens",
+ },
+ })
+ .input(z.object({ itemId: z.string(), kind: secretKindSchema, value: z.string().min(1).max(4096) }))
+ .mutation(async ({ ctx, input }) => {
+ await resolveItemWithBoardAccess(ctx, input.itemId);
+
+ const encrypted = encryptSecret(input.value);
+ const row = { itemId: input.itemId, kind: input.kind, value: encrypted, updatedAt: new Date() };
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (tx) => {
+ await tx
+ .delete(schema.widgetSecrets)
+ .where(and(eq(schema.widgetSecrets.itemId, input.itemId), eq(schema.widgetSecrets.kind, input.kind)));
+ await tx.insert(schema.widgetSecrets).values(row);
+ });
+ },
+ handleSync(db) {
+ db.transaction((tx) => {
+ tx.delete(widgetSecrets)
+ .where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind)))
+ .run();
+ tx.insert(widgetSecrets).values(row).run();
+ });
+ },
+ });
+ }),
+
+ deleteSecret: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Remove an authentication token for a release provider from a specific releases widget. Requires modify permission on the widget's board",
+ },
+ })
+ .input(z.object({ itemId: z.string(), kind: secretKindSchema }))
+ .mutation(async ({ ctx, input }) => {
+ await resolveItemWithBoardAccess(ctx, input.itemId);
+
+ await ctx.db
+ .delete(widgetSecrets)
+ .where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind)));
+ }),
+
+ getConfiguredKinds: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "List which release provider kinds have tokens configured for a releases widget. Returns an array of provider kind strings (e.g. github, gitlab, dockerHub). Requires modify permission on the widget's board",
+ },
+ })
+ .input(z.object({ itemId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ await resolveItemWithBoardAccess(ctx, input.itemId);
+
+ try {
+ const secrets = await ctx.db.query.widgetSecrets.findMany({
+ where: eq(widgetSecrets.itemId, input.itemId),
+ columns: { kind: true },
+ });
+ return secrets.map((s: { kind: string }) => s.kind);
+ } catch {
+ // ponytail: table may not exist pre-migration, return empty
+ return [] as string[];
+ }
+ }),
+});
diff --git a/packages/api/src/services/duckduckgo-bangs.ts b/packages/api/src/services/duckduckgo-bangs.ts
index 297f5c8ed..48de45dd6 100644
--- a/packages/api/src/services/duckduckgo-bangs.ts
+++ b/packages/api/src/services/duckduckgo-bangs.ts
@@ -39,7 +39,7 @@ export const searchDuckDuckGoBangsAsync = async (input: {
const queryTokenPrefix = normalizeBangToken(input.query);
if (!queryTokenPrefix) return [];
- const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({});
+ const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getDataAsync();
const startIndex = lowerBound(allBangs, queryTokenPrefix);
const matches: DuckDuckGoBang[] = [];
diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts
new file mode 100644
index 000000000..9ae1b8f04
--- /dev/null
+++ b/packages/api/src/settle-integrations.ts
@@ -0,0 +1,29 @@
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
+
+const logger = createLogger({ module: "settleIntegrations" });
+
+interface IntegrationLike {
+ id: string;
+ name: string;
+ kind: string;
+}
+
+export async function settleIntegrationQueries(
+ integrations: TIntegration[],
+ fn: (integration: TIntegration) => Promise,
+): Promise {
+ const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
+ return settled.flatMap((result, index) => {
+ if (result.status === "fulfilled") return [result.value];
+ const integration = integrations[index];
+ logger.warn(
+ new ErrorWithMetadata(
+ "Integration query failed",
+ { integrationId: integration?.id, integrationKind: integration?.kind },
+ { cause: result.reason },
+ ),
+ );
+ return [];
+ });
+}
diff --git a/packages/api/src/test/query-cache.spec.ts b/packages/api/src/test/query-cache.spec.ts
index c765af031..090544cb2 100644
--- a/packages/api/src/test/query-cache.spec.ts
+++ b/packages/api/src/test/query-cache.spec.ts
@@ -10,59 +10,31 @@ import {
isPersistableWidgetQueryKey,
queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes,
- queryCacheRetentionMs,
} from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
- const values = new Map();
- const calls: {
- userId: string;
- boardId: string;
- key: string;
- ttlMs: number;
- maxValueBytes: number;
- }[] = [];
+ const values = new Map();
return {
values,
- calls,
- createQueryCacheChannel: vi.fn(
- (options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
- calls.push(options);
- const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
-
- return {
- getAsync: async () => {
- const item = values.get(storageKey);
- if (!item) return null;
- if (Date.now() > item.expiresAt) {
- values.delete(storageKey);
- return null;
- }
-
- return item.value;
- },
- setAsync: async (value: string) => {
- if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) {
- return false;
- }
-
- values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs });
- return true;
- },
- removeAsync: async () => {
- values.delete(storageKey);
- },
- };
+ setQueryCacheAsync: vi.fn(
+ async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
+ if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
+ values.set(`${_userId}:${_boardId}`, value);
+ return true;
},
),
+ removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
+ values.delete(`${_userId}:${_boardId}`);
+ }),
};
});
vi.mock("@homarr/redis", () => ({
- createQueryCacheChannel: redisMock.createQueryCacheChannel,
+ setQueryCacheAsync: redisMock.setQueryCacheAsync,
+ removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
@@ -70,8 +42,8 @@ vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
- redisMock.calls.length = 0;
- redisMock.createQueryCacheChannel.mockClear();
+ redisMock.setQueryCacheAsync.mockClear();
+ redisMock.removeQueryCacheAsync.mockClear();
});
const createSession = (userId = createId()): Session => ({
@@ -104,7 +76,7 @@ describe("isPersistableWidgetQueryKey", () => {
});
describe("queryCacheRouter", () => {
- test("stores, reads, and removes data for a board viewer", async () => {
+ test("stores and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
@@ -112,14 +84,19 @@ describe("queryCacheRouter", () => {
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
- await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({
+ await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({
stored: true,
});
- await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
+ expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
+ session.user.id,
+ "board-1",
+ "cached-value",
+ expect.any(Number),
+ queryCacheMaxValueBytes,
+ );
- await caller.removeItem({ boardId: "board-1", key: "query-key" });
-
- await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
+ await caller.removeItem({ boardId: "board-1" });
+ expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
});
test("does not touch Redis when board access is denied", async () => {
@@ -127,32 +104,32 @@ describe("queryCacheRouter", () => {
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
- redisMock.createQueryCacheChannel.mockClear();
+ redisMock.setQueryCacheAsync.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
- await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found");
- expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
+ await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
+ expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled();
});
- test("scopes anonymous public-board cache entries by anonymous user and board", async () => {
+ test("scopes anonymous public-board cache entries by anonymous user", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
- await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
+ await caller.setItem({ boardId: "board-1", value: "cached-value" });
- expect(redisMock.calls.at(-1)).toMatchObject({
- userId: "anonymous",
- boardId: "board-1",
- key: "query-key",
- ttlMs: queryCacheRetentionMs,
- maxValueBytes: queryCacheMaxValueBytes,
- });
+ expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
+ "anonymous",
+ "board-1",
+ "cached-value",
+ expect.any(Number),
+ queryCacheMaxValueBytes,
+ );
});
- test("scopes signed-in cache entries by user and rejects oversized values", async () => {
+ test("rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
@@ -161,34 +138,9 @@ describe("queryCacheRouter", () => {
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
- await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({
+ await expect(caller.setItem({ boardId: "board-1", value })).resolves.toEqual({
stored: false,
});
- expect(redisMock.calls.at(-1)).toMatchObject({
- userId: "user-1",
- boardId: "board-1",
- ttlMs: queryCacheRetentionMs,
- maxValueBytes: queryCacheMaxValueBytes,
- });
- });
-
- test("expires values through the Redis TTL path", async () => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
-
- const db = createDb();
- const session = createSession("user-1");
- await insertUserAsync(db, session.user.id);
- await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
-
- const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
-
- await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
- await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
-
- await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1);
-
- await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
});
diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx
index c2ccc690a..b482fbe0c 100644
--- a/packages/boards/src/context.tsx
+++ b/packages/boards/src/context.tsx
@@ -20,16 +20,7 @@ export const BoardProvider = ({
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
}>) => {
- const { data } = clientApi.board.getBoardByName.useQuery(
- { name: initialBoard.name },
- {
- initialData: initialBoard,
- refetchOnWindowFocus: false,
- refetchOnReconnect: false,
- },
- );
-
- setActiveQueryCacheBoardId(data.id);
+ const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard });
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
diff --git a/packages/common/src/date.ts b/packages/common/src/date.ts
index de9ecadaf..5d46e45e1 100644
--- a/packages/common/src/date.ts
+++ b/packages/common/src/date.ts
@@ -17,6 +17,12 @@ export function formatDuration(milliseconds: number) {
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
+export const toValidDate = (value: unknown): Date | null => {
+ if (value == null || typeof value === "boolean") return null;
+ const date = value instanceof Date ? value : new Date(value as string | number);
+ return Number.isFinite(date.getTime()) ? date : null;
+};
+
export const isDateWithin = (date: Date, relativeDate: string): boolean => {
if (relativeDate.length < 2) {
throw new Error("Relative date must be at least 2 characters long");
diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts
index 576e001c9..d9e42493e 100644
--- a/packages/cron-jobs/src/index.ts
+++ b/packages/cron-jobs/src/index.ts
@@ -1,31 +1,6 @@
import { analyticsJob } from "./jobs/analytics";
-import { dockerContainersJob } from "./jobs/docker";
import { iconsUpdaterJob } from "./jobs/icons-updater";
-import { dnsHoleJob } from "./jobs/integrations/dns-hole";
-import { downloadsJob } from "./jobs/integrations/downloads";
-import {
- firewallCpuJob,
- firewallInterfacesJob,
- firewallMemoryJob,
- firewallVersionJob,
-} from "./jobs/integrations/firewall";
-import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
-import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
-import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
-import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
-import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
-import { mediaServerJob } from "./jobs/integrations/media-server";
-import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
-import { networkControllerJob } from "./jobs/integrations/network-controller";
-import { refreshNotificationsJob } from "./jobs/integrations/notifications";
-import { timetableJob } from "./jobs/integrations/timetable";
-import { tracearrJob } from "./jobs/integrations/tracearr";
-import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
-import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
-import { rssFeedsJob } from "./jobs/rss-feeds";
-import { updateCheckerJob } from "./jobs/update-checker";
-import { weatherJob } from "./jobs/weather";
import { createCronJobGroup } from "./lib";
const getJobGroup = () => {
@@ -33,34 +8,9 @@ const getJobGroup = () => {
analytics: analyticsJob,
iconsUpdater: iconsUpdaterJob,
ping: pingJob,
- smartHomeEntityState: smartHomeEntityStateJob,
- mediaServer: mediaServerJob,
- mediaOrganizer: mediaOrganizerJob,
- downloads: downloadsJob,
- dnsHole: dnsHoleJob,
- mediaRequestStats: mediaRequestStatsJob,
- mediaRequestList: mediaRequestListJob,
- rssFeeds: rssFeedsJob,
- indexerManager: indexerManagerJob,
- healthMonitoring: healthMonitoringJob,
- updateChecker: updateCheckerJob,
- mediaTranscoding: mediaTranscodingJob,
- minecraftServerStatus: minecraftServerStatusJob,
- dockerContainers: dockerContainersJob,
- networkController: networkControllerJob,
- firewallCpu: firewallCpuJob,
- firewallMemory: firewallMemoryJob,
- firewallVersion: firewallVersionJob,
- firewallInterfaces: firewallInterfacesJob,
- refreshNotifications: refreshNotificationsJob,
- weather: weatherJob,
- timetable: timetableJob,
- tracearr: tracearrJob,
- umamiActiveVisitors: umamiActiveVisitorsJob,
});
};
-// Add global type augmentation for homarr
declare global {
var cronJobs: ReturnType | undefined;
}
diff --git a/packages/cron-jobs/src/jobs/docker.ts b/packages/cron-jobs/src/jobs/docker.ts
deleted file mode 100644
index 963f395bf..000000000
--- a/packages/cron-jobs/src/jobs/docker.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import SuperJSON from "superjson";
-
-import { createLogger } from "@homarr/core/infrastructure/logs";
-import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
-import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
-import { db, eq } from "@homarr/db";
-import { items } from "@homarr/db/schema";
-import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
-
-import type { WidgetComponentProps } from "../../../widgets";
-import { createCronJob } from "../lib";
-
-const logger = createLogger({ module: "dockerJobs" });
-
-export const dockerContainersJob = createCronJob("dockerContainers", EVERY_MINUTE).withCallback(async () => {
- const dockerItems = await db.query.items.findMany({
- where: eq(items.kind, "dockerContainers"),
- });
-
- await Promise.allSettled(
- dockerItems.map(async (item) => {
- try {
- const options = SuperJSON.parse["options"]>(item.options);
- const innerHandler = dockerContainersRequestHandler.handler(options);
- await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
- } catch (error) {
- logger.error(new ErrorWithMetadata("Failed to update Docker container status", { item }, { cause: error }));
- }
- }),
- );
-});
diff --git a/packages/cron-jobs/src/jobs/integrations/dns-hole.ts b/packages/cron-jobs/src/jobs/integrations/dns-hole.ts
deleted file mode 100644
index a78b28e08..000000000
--- a/packages/cron-jobs/src/jobs/integrations/dns-hole.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
-import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-
-import { createCronJob } from "../../lib";
-
-export const dnsHoleJob = createCronJob("dnsHole", EVERY_MINUTE).withCallback(
- createRequestIntegrationJobHandler(dnsHoleRequestHandler.handler, {
- widgetKinds: ["dnsHoleSummary", "dnsHoleControls"],
- getInput: {
- dnsHoleSummary: () => ({}),
- dnsHoleControls: () => ({}),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/downloads.ts b/packages/cron-jobs/src/jobs/integrations/downloads.ts
deleted file mode 100644
index 54eb476dc..000000000
--- a/packages/cron-jobs/src/jobs/integrations/downloads.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
-import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-
-import { createCronJob } from "../../lib";
-
-export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(
- createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, {
- widgetKinds: ["downloads"],
- getInput: {
- downloads: (options) => ({
- limit: options.limitPerIntegration,
- }),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/firewall.ts b/packages/cron-jobs/src/jobs/integrations/firewall.ts
deleted file mode 100644
index 50637a642..000000000
--- a/packages/cron-jobs/src/jobs/integrations/firewall.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { EVERY_5_SECONDS, EVERY_30_SECONDS, EVERY_HOUR, EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
-import {
- firewallCpuRequestHandler,
- firewallInterfacesRequestHandler,
- firewallMemoryRequestHandler,
- firewallVersionRequestHandler,
-} from "@homarr/request-handler/firewall";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-
-import { createCronJob } from "../../lib";
-
-export const firewallCpuJob = createCronJob("firewallCpu", EVERY_5_SECONDS).withCallback(
- createRequestIntegrationJobHandler(firewallCpuRequestHandler.handler, {
- widgetKinds: ["firewall"],
- getInput: {
- firewall: () => ({}),
- },
- }),
-);
-
-export const firewallMemoryJob = createCronJob("firewallMemory", EVERY_MINUTE).withCallback(
- createRequestIntegrationJobHandler(firewallMemoryRequestHandler.handler, {
- widgetKinds: ["firewall"],
- getInput: {
- firewall: () => ({}),
- },
- }),
-);
-
-export const firewallInterfacesJob = createCronJob("firewallInterfaces", EVERY_30_SECONDS).withCallback(
- createRequestIntegrationJobHandler(firewallInterfacesRequestHandler.handler, {
- widgetKinds: ["firewall"],
- getInput: {
- firewall: () => ({}),
- },
- }),
-);
-
-export const firewallVersionJob = createCronJob("firewallVersion", EVERY_HOUR).withCallback(
- createRequestIntegrationJobHandler(firewallVersionRequestHandler.handler, {
- widgetKinds: ["firewall"],
- getInput: {
- firewall: () => ({}),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts
deleted file mode 100644
index 4d09801d2..000000000
--- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
-import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
-import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-
-import { createCronJob } from "../../lib";
-
-export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
- createRequestIntegrationJobHandler(
- (integration, itemOptions: Record) => {
- const { kind } = integration;
-
- if (kind === "beszel") {
- return null as never;
- }
- if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
- return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
- }
- if (kind === "coolify") {
- return coolifyRequestHandler.handler({ ...integration, kind }, itemOptions);
- }
- return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
- },
- {
- widgetKinds: ["healthMonitoring", "systemResources", "coolify"],
- getInput: {
- healthMonitoring: () => ({}),
- systemResources: () => ({}),
- coolify: () => ({}),
- },
- },
- ),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/home-assistant.ts b/packages/cron-jobs/src/jobs/integrations/home-assistant.ts
deleted file mode 100644
index 2723391a7..000000000
--- a/packages/cron-jobs/src/jobs/integrations/home-assistant.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
-
-import { createCronJob } from "../../lib";
-
-export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(
- createRequestIntegrationJobHandler(smartHomeEntityStateRequestHandler.handler, {
- widgetKinds: ["smartHome-entityState"],
- getInput: {
- "smartHome-entityState": (options) => ({
- entityId: options.entityId,
- }),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts b/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts
deleted file mode 100644
index 11da75508..000000000
--- a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
-import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-
-import { createCronJob } from "../../lib";
-
-export const indexerManagerJob = createCronJob("indexerManager", EVERY_5_MINUTES).withCallback(
- createRequestIntegrationJobHandler(indexerManagerRequestHandler.handler, {
- widgetKinds: ["indexerManager"],
- getInput: {
- indexerManager: () => ({}),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts
deleted file mode 100644
index a84696da9..000000000
--- a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import dayjs from "dayjs";
-
-import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
-import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-
-import { createCronJob } from "../../lib";
-
-export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(
- createRequestIntegrationJobHandler(calendarMonthRequestHandler.handler, {
- widgetKinds: ["calendar"],
- getInput: {
- // Request handler will run for all specified months
- calendar: (options) => {
- const inputs = [];
-
- const startOffset = -Number(options.filterPastMonths);
- const endOffset = Number(options.filterFutureMonths);
-
- for (let offsetMonths = startOffset; offsetMonths <= endOffset; offsetMonths++) {
- const year = dayjs().subtract(offsetMonths, "months").year();
- const month = dayjs().subtract(offsetMonths, "months").month();
-
- inputs.push({
- year,
- month,
- releaseType: options.releaseType,
- showUnmonitored: options.showUnmonitored,
- });
- }
-
- return inputs;
- },
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/media-requests.ts b/packages/cron-jobs/src/jobs/integrations/media-requests.ts
deleted file mode 100644
index ededb6141..000000000
--- a/packages/cron-jobs/src/jobs/integrations/media-requests.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
-import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
-
-import { createCronJob } from "../../lib";
-
-export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MINUTE).withCallback(
- createRequestIntegrationJobHandler(mediaRequestStatsRequestHandler.handler, {
- widgetKinds: ["mediaRequests-requestStats"],
- getInput: {
- "mediaRequests-requestStats": () => ({}),
- },
- }),
-);
-
-export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
- createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
- widgetKinds: ["mediaRequests-requestList"],
- getInput: {
- "mediaRequests-requestList": () => ({}),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/media-server.ts b/packages/cron-jobs/src/jobs/integrations/media-server.ts
deleted file mode 100644
index 099ea53ae..000000000
--- a/packages/cron-jobs/src/jobs/integrations/media-server.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
-
-import { createCronJob } from "../../lib";
-
-export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(
- createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, {
- widgetKinds: ["mediaServer"],
- getInput: {
- mediaServer: ({ showOnlyPlaying }) => ({ showOnlyPlaying }),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts b/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts
deleted file mode 100644
index c039dc250..000000000
--- a/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
-
-import { createCronJob } from "../../lib";
-
-export const mediaTranscodingJob = createCronJob("mediaTranscoding", EVERY_5_MINUTES).withCallback(
- createRequestIntegrationJobHandler(mediaTranscodingRequestHandler.handler, {
- widgetKinds: ["mediaTranscoding"],
- getInput: {
- mediaTranscoding: () => ({ pageOffset: 0, pageSize: 10 }),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/network-controller.ts b/packages/cron-jobs/src/jobs/integrations/network-controller.ts
deleted file mode 100644
index 5d9354588..000000000
--- a/packages/cron-jobs/src/jobs/integrations/network-controller.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
-
-import { createCronJob } from "../../lib";
-
-export const networkControllerJob = createCronJob("networkController", EVERY_MINUTE).withCallback(
- createRequestIntegrationJobHandler(networkControllerRequestHandler.handler, {
- widgetKinds: ["networkControllerSummary"],
- getInput: {
- networkControllerSummary: () => ({}),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/notifications.ts b/packages/cron-jobs/src/jobs/integrations/notifications.ts
deleted file mode 100644
index 0f544995f..000000000
--- a/packages/cron-jobs/src/jobs/integrations/notifications.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
-
-import { createCronJob } from "../../lib";
-
-export const refreshNotificationsJob = createCronJob("refreshNotifications", EVERY_5_MINUTES).withCallback(
- createRequestIntegrationJobHandler(notificationsRequestHandler.handler, {
- widgetKinds: ["notifications"],
- getInput: {
- notifications: (options) => options,
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/timetable.ts b/packages/cron-jobs/src/jobs/integrations/timetable.ts
deleted file mode 100644
index b3964dc38..000000000
--- a/packages/cron-jobs/src/jobs/integrations/timetable.ts
+++ /dev/null
@@ -1,20 +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 { timetableGetTimetableRequestHandler } from "@homarr/request-handler/timetable";
-
-import { createCronJob } from "../../lib";
-
-export const timetableJob = createCronJob("timetable", EVERY_5_MINUTES).withCallback(
- createRequestIntegrationJobHandler(timetableGetTimetableRequestHandler.handler, {
- widgetKinds: ["timetable"],
- getInput: {
- timetable: (options) =>
- options.station
- ? {
- stationId: options.station.value,
- limit: 10,
- }
- : [],
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/tracearr.ts b/packages/cron-jobs/src/jobs/integrations/tracearr.ts
deleted file mode 100644
index f80130374..000000000
--- a/packages/cron-jobs/src/jobs/integrations/tracearr.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
-
-import { createCronJob } from "../../lib";
-
-export const tracearrJob = createCronJob("tracearr", EVERY_5_SECONDS).withCallback(
- createRequestIntegrationJobHandler(tracearrRequestHandler.handler, {
- widgetKinds: ["tracearr"],
- getInput: {
- tracearr: () => ({}),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/integrations/umami.ts b/packages/cron-jobs/src/jobs/integrations/umami.ts
deleted file mode 100644
index c50a3c604..000000000
--- a/packages/cron-jobs/src/jobs/integrations/umami.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
-import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
-import { umamiActiveVisitorsRequestHandler } from "@homarr/request-handler/umami";
-
-import { createCronJob } from "../../lib";
-
-export const umamiActiveVisitorsJob = createCronJob("umamiActiveVisitors", EVERY_30_SECONDS).withCallback(
- createRequestIntegrationJobHandler(umamiActiveVisitorsRequestHandler.handler, {
- widgetKinds: ["umami"],
- getInput: {
- umami: (options) => (options.websiteId ? { websiteId: options.websiteId } : []),
- },
- }),
-);
diff --git a/packages/cron-jobs/src/jobs/minecraft-server-status.ts b/packages/cron-jobs/src/jobs/minecraft-server-status.ts
deleted file mode 100644
index e6c084702..000000000
--- a/packages/cron-jobs/src/jobs/minecraft-server-status.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import SuperJSON from "superjson";
-
-import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
-import { db, eq } from "@homarr/db";
-import { items } from "@homarr/db/schema";
-import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
-
-import type { WidgetComponentProps } from "../../../widgets/src";
-import { createCronJob } from "../lib";
-
-export const minecraftServerStatusJob = createCronJob("minecraftServerStatus", EVERY_5_MINUTES).withCallback(
- async () => {
- const dbItems = await db.query.items.findMany({
- where: eq(items.kind, "minecraftServerStatus"),
- });
-
- await Promise.allSettled(
- dbItems.map(async (item) => {
- const options = SuperJSON.parse["options"]>(item.options);
- const innerHandler = minecraftServerStatusRequestHandler.handler(options);
- await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
- }),
- );
- },
-);
diff --git a/packages/cron-jobs/src/jobs/rss-feeds.ts b/packages/cron-jobs/src/jobs/rss-feeds.ts
deleted file mode 100644
index 6a497a043..000000000
--- a/packages/cron-jobs/src/jobs/rss-feeds.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import SuperJSON from "superjson";
-
-import { createLogger } from "@homarr/core/infrastructure/logs";
-import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
-import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions";
-import { db, eq } from "@homarr/db";
-import { items } from "@homarr/db/schema";
-import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
-
-// This import is done that way to avoid circular dependencies.
-import type { WidgetComponentProps } from "../../../widgets";
-import { createCronJob } from "../lib";
-
-const logger = createLogger({ module: "rssFeedsJobs" });
-
-export const rssFeedsJob = createCronJob("rssFeeds", EVERY_10_MINUTES).withCallback(async () => {
- const rssItems = await db.query.items.findMany({
- where: eq(items.kind, "rssFeed"),
- });
-
- const itemOptions = rssItems.map((item) => SuperJSON.parse["options"]>(item.options));
-
- for (const option of itemOptions) {
- const maxAmountPosts = typeof option.maximumAmountPosts === "number" ? option.maximumAmountPosts : 100;
- for (const url of option.feedUrls) {
- try {
- const innerHandler = rssFeedsRequestHandler.handler({
- url,
- count: maxAmountPosts,
- });
- await innerHandler.getCachedOrUpdatedDataAsync({
- forceUpdate: true,
- });
- } catch (error) {
- logger.error(new ErrorWithMetadata("Failed to update RSS feed", { url }, { cause: error }));
- }
- }
- }
-});
diff --git a/packages/cron-jobs/src/jobs/update-checker.ts b/packages/cron-jobs/src/jobs/update-checker.ts
deleted file mode 100644
index eea68d07b..000000000
--- a/packages/cron-jobs/src/jobs/update-checker.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { EVERY_DAY } from "@homarr/cron-jobs-core/expressions";
-import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
-
-import { createCronJob } from "../lib";
-
-export const updateCheckerJob = createCronJob("updateChecker", EVERY_DAY, {
- runOnStart: true,
-}).withCallback(async () => {
- const handler = updateCheckerRequestHandler.handler({});
- await handler.getCachedOrUpdatedDataAsync({});
-});
diff --git a/packages/cron-jobs/src/jobs/weather.ts b/packages/cron-jobs/src/jobs/weather.ts
deleted file mode 100644
index 1b23ade4c..000000000
--- a/packages/cron-jobs/src/jobs/weather.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import SuperJSON from "superjson";
-
-import { createLogger } from "@homarr/core/infrastructure/logs";
-import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
-import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions";
-import { db, eq } from "@homarr/db";
-import { items } from "@homarr/db/schema";
-import { weatherRequestHandler } from "@homarr/request-handler/weather";
-
-import type { WidgetComponentProps } from "../../../widgets";
-import { createCronJob } from "../lib";
-
-const logger = createLogger({ module: "weatherJobs" });
-
-export const weatherJob = createCronJob("weather", EVERY_10_MINUTES).withCallback(async () => {
- const weatherItems = await db.query.items.findMany({
- where: eq(items.kind, "weather"),
- });
-
- const parsedItems = weatherItems.map((item) => ({
- id: item.id,
- options: SuperJSON.parse["options"]>(item.options),
- }));
-
- for (const item of parsedItems) {
- try {
- const innerHandler = weatherRequestHandler.handler({
- longitude: item.options.location.longitude,
- latitude: item.options.location.latitude,
- });
- await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
- } catch (error) {
- logger.error(new ErrorWithMetadata("Failed to update weather", { id: item.id }, { cause: error }));
- }
- }
-});
diff --git a/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts b/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts
index 951831482..85f39bcbc 100644
--- a/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts
+++ b/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts
@@ -1,8 +1,7 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
-import type { IntegrationKind } from "@homarr/definitions";
-import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { releaseProviderKinds } from "@homarr/definitions";
import { eq } from "../..";
import type { Database } from "../..";
@@ -13,19 +12,6 @@ export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) {
where: (items, { eq }) => eq(items.kind, "releases"),
});
- const integrationKinds = getIntegrationKindsByCategory("releasesProvider");
- const providerIntegrations = await db.query.integrations.findMany({
- where: (integrations, { inArray }) => inArray(integrations.kind, integrationKinds),
- columns: {
- id: true,
- kind: true,
- },
- });
-
- const providerIntegrationMap = new Map(
- providerIntegrations.map((integration) => [integration.kind, integration.id]),
- );
-
const updates: {
id: string;
options: object;
@@ -37,18 +23,19 @@ export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) {
if (options.repositories.length === 0) continue;
if (!options.repositories.some((repository) => "providerKey" in repository)) continue;
- const updatedRepositories = options.repositories.map(
- ({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
- // Ensure providerKey is camelCase
+ const updatedRepositories = options.repositories
+ .map(({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
const provider = providerKey.charAt(0).toLowerCase() + providerKey.slice(1);
+ const matchedProvider = releaseProviderKinds.find((kind) => kind === provider);
+ if (!matchedProvider) return null;
return {
id: createId(),
- providerIntegrationId: providerIntegrationMap.get(provider as IntegrationKind) ?? null,
+ provider: matchedProvider,
...otherFields,
};
- },
- );
+ })
+ .filter((repo) => repo !== null);
updates.push({
id: item.id,
@@ -68,5 +55,5 @@ export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) {
.where(eq(items.id, update.id));
}
- console.log(`Migrated release widget providers to integrations count="${updates.length}"`);
+ console.log(`Migrated release widget providers to options count="${updates.length}"`);
}
diff --git a/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts b/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts
new file mode 100644
index 000000000..0c7a04d6c
--- /dev/null
+++ b/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts
@@ -0,0 +1,100 @@
+import SuperJSON from "superjson";
+
+import { eq, inArray } from "../..";
+import type { Database } from "../..";
+import { integrationItems, integrations, items } from "../../schema";
+
+const removedIntegrationKinds = [
+ "github",
+ "dockerHub",
+ "gitlab",
+ "npm",
+ "codeberg",
+ "linuxServerIO",
+ "gitHubContainerRegistry",
+ "quay",
+ "searchCh",
+] as const;
+
+type RemovedIntegrationKind = (typeof removedIntegrationKinds)[number];
+
+const releaseProviderKinds = [
+ "github",
+ "dockerHub",
+ "gitlab",
+ "npm",
+ "codeberg",
+ "linuxServerIO",
+ "gitHubContainerRegistry",
+ "quay",
+] as const;
+
+const isReleaseProviderKind = (kind: RemovedIntegrationKind): kind is (typeof releaseProviderKinds)[number] =>
+ releaseProviderKinds.some((provider) => provider === kind);
+
+export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database) {
+ const oldIntegrations = await db.query.integrations.findMany({
+ columns: { id: true, kind: true, url: true },
+ });
+ const oldIntegrationById = new Map(
+ oldIntegrations
+ .filter((integration) => removedIntegrationKinds.includes(integration.kind as RemovedIntegrationKind))
+ .map((integration) => [
+ integration.id,
+ { kind: integration.kind as RemovedIntegrationKind, url: integration.url },
+ ]),
+ );
+
+ if (oldIntegrationById.size === 0) return;
+
+ const oldIntegrationIds = Array.from(oldIntegrationById.keys());
+ const oldIntegrationItems = await db.query.integrationItems.findMany();
+ const oldIntegrationItemByItemId = new Map(
+ oldIntegrationItems
+ .filter((item) => oldIntegrationById.has(item.integrationId))
+ .map((item) => [item.itemId, oldIntegrationById.get(item.integrationId)]),
+ );
+ const existingItems = await db.query.items.findMany({
+ where: (items, { inArray }) => inArray(items.kind, ["releases", "timetable"]),
+ });
+
+ const updatedItems: { id: string; options: object }[] = [];
+
+ for (const item of existingItems) {
+ const options = SuperJSON.parse>(item.options);
+ const linkedIntegration = oldIntegrationItemByItemId.get(item.id);
+
+ if (item.kind === "releases" && Array.isArray(options.repositories)) {
+ const repositories = options.repositories.map((repository) => {
+ if (typeof repository !== "object" || repository === null) return repository;
+ const { providerIntegrationId, ...rest } = repository as Record;
+ const provider =
+ typeof providerIntegrationId === "string" ? oldIntegrationById.get(providerIntegrationId) : undefined;
+ const existingProviderUrl =
+ typeof rest.providerUrl === "string" && rest.providerUrl.length > 0 ? rest.providerUrl : undefined;
+ return {
+ ...rest,
+ provider: provider && isReleaseProviderKind(provider.kind) ? provider.kind : rest.provider,
+ providerUrl: provider?.url ?? existingProviderUrl,
+ };
+ });
+ updatedItems.push({ id: item.id, options: { ...options, repositories } });
+ }
+
+ if (item.kind === "timetable" && linkedIntegration?.kind === "searchCh") {
+ updatedItems.push({ id: item.id, options: { ...options, baseUrl: linkedIntegration.url } });
+ }
+ }
+
+ for (const update of updatedItems) {
+ await db
+ .update(items)
+ .set({ options: SuperJSON.stringify(update.options) })
+ .where(eq(items.id, update.id));
+ }
+
+ await db.delete(integrationItems).where(inArray(integrationItems.integrationId, oldIntegrationIds));
+ await db.delete(integrations).where(inArray(integrations.id, oldIntegrationIds));
+
+ console.log(`Migrated widget-only integrations to widget options count="${updatedItems.length}"`);
+}
diff --git a/packages/db/migrations/custom/index.ts b/packages/db/migrations/custom/index.ts
index a6a002733..8bcc93c32 100644
--- a/packages/db/migrations/custom/index.ts
+++ b/packages/db/migrations/custom/index.ts
@@ -2,9 +2,11 @@ import type { Database } from "../..";
import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widget_provider_to_options";
import { migrateOpnsenseCredentialsAsync } from "./0001_opnsense_credentials";
import { migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync } from "./0002_app_widget_show_description_tooltip_to_display_mode";
+import { migrateWidgetOnlyIntegrationsToOptionsAsync } from "./0003_remove_widget_only_integrations";
export const applyCustomMigrationsAsync = async (db: Database) => {
await migrateReleaseWidgetProviderToOptionsAsync(db);
await migrateOpnsenseCredentialsAsync(db);
await migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync(db);
+ await migrateWidgetOnlyIntegrationsToOptionsAsync(db);
};
diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts
index 59566631f..8c3b7f4fc 100644
--- a/packages/db/schema/index.ts
+++ b/packages/db/schema/index.ts
@@ -51,6 +51,7 @@ export const {
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
+ widgetSecrets,
} = schema;
export type User = InferSelectModel;
diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts
index 372a2aa9f..71af5dbd2 100644
--- a/packages/db/schema/mysql.ts
+++ b/packages/db/schema/mysql.ts
@@ -541,6 +541,25 @@ export const customWidgetSecrets = mysqlTable(
}),
);
+export const widgetSecrets = mysqlTable(
+ "widget_secret",
+ {
+ itemId: varchar({ length: 64 })
+ .notNull()
+ .references(() => items.id, { onDelete: "cascade" }),
+ kind: varchar({ length: 64 }).notNull(),
+ value: text().$type<`${string}.${string}`>().notNull(),
+ updatedAt: timestamp()
+ .$onUpdateFn(() => new Date())
+ .notNull(),
+ },
+ (table) => ({
+ compoundKey: primaryKey({
+ columns: [table.itemId, table.kind],
+ }),
+ }),
+);
+
export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
@@ -746,12 +765,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
layouts: many(itemLayouts),
+ secrets: many(widgetSecrets),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
+export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({
+ item: one(items, {
+ fields: [widgetSecrets.itemId],
+ references: [items.id],
+ }),
+}));
+
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts
index 0e104f7f4..092ecc64b 100644
--- a/packages/db/schema/postgresql.ts
+++ b/packages/db/schema/postgresql.ts
@@ -540,6 +540,25 @@ export const customWidgetSecrets = pgTable(
}),
);
+export const widgetSecrets = pgTable(
+ "widget_secret",
+ {
+ itemId: varchar({ length: 64 })
+ .notNull()
+ .references(() => items.id, { onDelete: "cascade" }),
+ kind: varchar({ length: 64 }).notNull(),
+ value: text().$type<`${string}.${string}`>().notNull(),
+ updatedAt: timestamp()
+ .$onUpdateFn(() => new Date())
+ .notNull(),
+ },
+ (table) => ({
+ compoundKey: primaryKey({
+ columns: [table.itemId, table.kind],
+ }),
+ }),
+);
+
export const cronJobConfigurations = pgTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
@@ -745,12 +764,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
layouts: many(itemLayouts),
+ secrets: many(widgetSecrets),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
+export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({
+ item: one(items, {
+ fields: [widgetSecrets.itemId],
+ references: [items.id],
+ }),
+}));
+
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index 309d58507..b791609d4 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -530,6 +530,25 @@ export const customWidgetSecrets = sqliteTable(
}),
);
+export const widgetSecrets = sqliteTable(
+ "widget_secret",
+ {
+ itemId: text()
+ .notNull()
+ .references(() => items.id, { onDelete: "cascade" }),
+ kind: text().notNull(),
+ value: text().$type<`${string}.${string}`>().notNull(),
+ updatedAt: int({ mode: "timestamp" })
+ .$onUpdateFn(() => new Date())
+ .notNull(),
+ },
+ (table) => ({
+ compoundKey: primaryKey({
+ columns: [table.itemId, table.kind],
+ }),
+ }),
+);
+
export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
name: text().notNull().primaryKey(),
cronExpression: text().notNull(),
@@ -735,12 +754,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
layouts: many(itemLayouts),
+ secrets: many(widgetSecrets),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
+export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({
+ item: one(items, {
+ fields: [widgetSecrets.itemId],
+ references: [items.id],
+ }),
+}));
+
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts
index 9dcba12d4..e7e10809b 100644
--- a/packages/definitions/src/docker-integration-match.ts
+++ b/packages/definitions/src/docker-integration-match.ts
@@ -31,9 +31,6 @@ const integrationAliases: Partial> =
audiobookshelf: ["audiobookshelf"],
navidrome: ["navidrome"],
paperlessNgx: ["paperless-ngx", "paperless"],
- searchCh: ["search-ch"],
- gitHubContainerRegistry: ["ghcr"],
- linuxServerIO: ["linuxserver"],
coolify: ["coolify"],
truenas: ["truenas"],
};
@@ -63,19 +60,7 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
return null;
};
-const notDockerDiscoverable = new Set([
- "dockerHub",
- "github",
- "gitlab",
- "npm",
- "codeberg",
- "linuxServerIO",
- "gitHubContainerRegistry",
- "quay",
- "ical",
- "searchCh",
- "mock",
-]);
+const notDockerDiscoverable = new Set(["ical", "mock"]);
interface ContainerMatchInput {
image: string;
diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts
index 1af579b5f..307f74428 100644
--- a/packages/definitions/src/docs/homarr-docs-sitemap.ts
+++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts
@@ -165,20 +165,17 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/adguard-home"
| "/docs/integrations/anchor"
+ | "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/beszel"
- | "/docs/integrations/codeberg"
| "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
| "/docs/integrations/deluge"
- | "/docs/integrations/docker-hub"
| "/docs/integrations/docker"
| "/docs/integrations/emby"
- | "/docs/integrations/github-containerregistry"
- | "/docs/integrations/github"
- | "/docs/integrations/gitlab"
| "/docs/integrations/glances"
+ | "/docs/integrations/gluetun"
| "/docs/integrations/gotify"
| "/docs/integrations/home-assistant"
| "/docs/integrations/ical"
@@ -187,10 +184,8 @@ export type HomarrDocumentationPath =
| "/docs/integrations/jellyseerr"
| "/docs/integrations/kubernetes"
| "/docs/integrations/lidarr"
- | "/docs/integrations/linux-server-io"
| "/docs/integrations/navidrome"
| "/docs/integrations/nextcloud"
- | "/docs/integrations/npm"
| "/docs/integrations/ntfy"
| "/docs/integrations/nzbget"
| "/docs/integrations/open-media-vault"
@@ -203,11 +198,9 @@ export type HomarrDocumentationPath =
| "/docs/integrations/prowlarr"
| "/docs/integrations/proxmox"
| "/docs/integrations/q-bittorent"
- | "/docs/integrations/quay"
| "/docs/integrations/radarr"
| "/docs/integrations/readarr"
| "/docs/integrations/sabnzbd"
- | "/docs/integrations/search-ch"
| "/docs/integrations/seerr"
| "/docs/integrations/slskd"
| "/docs/integrations/sonarr"
@@ -280,6 +273,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/video"
+ | "/docs/widgets/vpn"
| "/docs/widgets/weather"
| ""
| "/sitemap.xml"
@@ -313,14 +307,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/nextcloud"
| "/docs/integrations/unifi-controller"
| "/docs/integrations/opnsense"
- | "/docs/integrations/github"
- | "/docs/integrations/docker-hub"
- | "/docs/integrations/gitlab"
- | "/docs/integrations/npm"
- | "/docs/integrations/codeberg"
- | "/docs/integrations/linux-server-io"
- | "/docs/integrations/github-containerregistry"
- | "/docs/integrations/quay"
| "/docs/integrations/ntfy"
| "/docs/integrations/gotify"
| "/docs/integrations/ical"
@@ -328,7 +314,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/truenas"
| "/docs/integrations/unraid"
| "/docs/integrations/coolify"
- | "/docs/integrations/search-ch"
| "/docs/integrations/immich"
| "/docs/integrations/paperless-ngx"
| "/docs/integrations/tracearr"
@@ -337,6 +322,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/navidrome"
| "/docs/integrations/umami"
+ | "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/widgets/clock"
@@ -380,6 +366,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/audio-stats"
| "/docs/widgets/umami"
+ | "/docs/widgets/vpn"
| "/docs/widgets/ups"
| "/docs/widgets/beszel-system-table"
| "/docs/widgets/beszel-system-grid"
diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts
index 99ec6ebb0..ca19d4de6 100644
--- a/packages/definitions/src/docs/integration-doc-slugs.ts
+++ b/packages/definitions/src/docs/integration-doc-slugs.ts
@@ -31,14 +31,6 @@ export const integrationDocSlugs: Record = {
nextcloud: "nextcloud",
unifiController: "unifi-controller",
opnsense: "opnsense",
- github: "github",
- dockerHub: "docker-hub",
- gitlab: "gitlab",
- npm: "npm",
- codeberg: "codeberg",
- linuxServerIO: "linux-server-io",
- gitHubContainerRegistry: "github-containerregistry",
- quay: "quay",
ntfy: "ntfy",
gotify: "gotify",
ical: "ical",
@@ -46,7 +38,6 @@ export const integrationDocSlugs: Record = {
truenas: "truenas",
unraid: "unraid",
coolify: "coolify",
- searchCh: "search-ch",
immich: "immich",
paperlessNgx: "paperless-ngx",
tracearr: "tracearr",
@@ -55,6 +46,7 @@ export const integrationDocSlugs: Record = {
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
+ gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts
index 3ffae6408..2e34a4843 100644
--- a/packages/definitions/src/docs/widget-doc-slugs.ts
+++ b/packages/definitions/src/docs/widget-doc-slugs.ts
@@ -43,6 +43,7 @@ export const widgetDocSlugs: Record = {
uptimeKuma: "uptime-kuma",
audioStats: "audio-stats",
umami: "umami",
+ vpn: "vpn",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts
index eb81e4d18..94b392ee7 100644
--- a/packages/definitions/src/index.ts
+++ b/packages/definitions/src/index.ts
@@ -17,3 +17,4 @@ export * from "./emptysuperjson";
export * from "./hotkeys";
export * from "./docker-integration-match";
export * from "./integration-url-template";
+export * from "./release-provider";
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
index 8efa552e4..15c94f80d 100644
--- a/packages/definitions/src/integration.ts
+++ b/packages/definitions/src/integration.ts
@@ -283,70 +283,6 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
defaultPort: 443,
},
- github: {
- name: "Github",
- secretKinds: [[], ["personalAccessToken"], ["githubAppId", "githubInstallationId", "privateKey"]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
- category: ["releasesProvider"],
- defaultUrl: "https://api.github.com",
- documentationUrl: createDocumentationLink("/docs/integrations/github"),
- },
- dockerHub: {
- name: "Docker Hub",
- secretKinds: [[], ["username", "personalAccessToken"]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
- category: ["releasesProvider"],
- defaultUrl: "https://hub.docker.com",
- documentationUrl: createDocumentationLink("/docs/integrations/docker-hub"),
- },
- gitlab: {
- name: "Gitlab",
- secretKinds: [[], ["personalAccessToken"]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg",
- category: ["releasesProvider"],
- defaultUrl: "https://gitlab.com",
- documentationUrl: createDocumentationLink("/docs/integrations/gitlab"),
- },
- npm: {
- name: "NPM",
- secretKinds: [[]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg",
- category: ["releasesProvider"],
- defaultUrl: "https://registry.npmjs.org",
- documentationUrl: createDocumentationLink("/docs/integrations/npm"),
- },
- codeberg: {
- name: "Codeberg",
- secretKinds: [[], ["personalAccessToken"]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg",
- category: ["releasesProvider"],
- defaultUrl: "https://codeberg.org",
- documentationUrl: createDocumentationLink("/docs/integrations/codeberg"),
- },
- linuxServerIO: {
- name: "LinuxServer.io",
- secretKinds: [[]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg",
- category: ["releasesProvider"],
- defaultUrl: "https://api.linuxserver.io",
- documentationUrl: createDocumentationLink("/docs/integrations/linux-server-io"),
- },
- gitHubContainerRegistry: {
- name: "GitHub Container Registry",
- secretKinds: [[], ["personalAccessToken"], ["githubAppId", "githubInstallationId", "privateKey"]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
- category: ["releasesProvider"],
- defaultUrl: "https://api.github.com",
- documentationUrl: createDocumentationLink("/docs/integrations/github"),
- },
- quay: {
- name: "Quay",
- secretKinds: [[], ["personalAccessToken"]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png",
- category: ["releasesProvider"],
- defaultUrl: "https://quay.io",
- documentationUrl: createDocumentationLink("/docs/integrations/quay"),
- },
ntfy: {
name: "ntfy",
secretKinds: [["topic"], ["topic", "apiKey"]],
@@ -401,13 +337,6 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
defaultPort: 8000,
},
- searchCh: {
- name: "Search.ch",
- secretKinds: [[]],
- iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png",
- category: ["timetable"],
- documentationUrl: createDocumentationLink("/docs/integrations/search-ch"),
- },
immich: {
name: "Immich",
secretKinds: [["apiKey"]],
@@ -489,13 +418,20 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
defaultPort: 8090,
},
+ gluetun: {
+ name: "Gluetun",
+ secretKinds: [["username", "password"], ["apiKey"], []],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
+ category: ["vpn"],
+ documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
+ },
archiveTeamWarrior: {
name: "ArchiveTeam Warrior",
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
- defaultUrl: "http://localhost:8001",
documentationUrl: null,
+ defaultUrl: "http://localhost:8001",
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
@@ -595,7 +531,6 @@ export const integrationCategories = [
"search",
"mediaTranscoding",
"networkController",
- "releasesProvider",
"notifications",
"firewall",
"timetable",
@@ -604,6 +539,7 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
+ "vpn",
"archiving",
"ups",
"documents",
diff --git a/packages/definitions/src/release-provider.ts b/packages/definitions/src/release-provider.ts
new file mode 100644
index 000000000..3fa53cc8f
--- /dev/null
+++ b/packages/definitions/src/release-provider.ts
@@ -0,0 +1,78 @@
+import { objectKeys } from "@homarr/common";
+
+export const releaseProviderDefs = {
+ github: {
+ name: "Github",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
+ defaultUrl: "https://api.github.com",
+ },
+ dockerHub: {
+ name: "Docker Hub",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
+ defaultUrl: "https://hub.docker.com",
+ },
+ gitlab: {
+ name: "Gitlab",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg",
+ defaultUrl: "https://gitlab.com",
+ },
+ npm: {
+ name: "NPM",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg",
+ defaultUrl: "https://registry.npmjs.org",
+ },
+ codeberg: {
+ name: "Codeberg",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg",
+ defaultUrl: "https://codeberg.org",
+ },
+ linuxServerIO: {
+ name: "LinuxServer.io",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg",
+ defaultUrl: "https://api.linuxserver.io",
+ },
+ gitHubContainerRegistry: {
+ name: "GitHub Container Registry",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
+ defaultUrl: "https://ghcr.io",
+ },
+ quay: {
+ name: "Quay",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png",
+ defaultUrl: "https://quay.io",
+ },
+} as const;
+
+export const releaseProviderKinds = objectKeys(releaseProviderDefs);
+export type ReleaseProviderKind = keyof typeof releaseProviderDefs;
+
+export const getReleaseProviderName = (provider: ReleaseProviderKind) => releaseProviderDefs[provider].name;
+export const getReleaseProviderIconUrl = (provider: ReleaseProviderKind) => releaseProviderDefs[provider].iconUrl;
+export const getReleaseProviderDefaultUrl = (provider: ReleaseProviderKind) => releaseProviderDefs[provider].defaultUrl;
+
+const releaseProviderRegistryPrefixes: Partial> = {
+ dockerHub: "docker.io/",
+ gitHubContainerRegistry: "ghcr.io/",
+ linuxServerIO: "lscr.io/",
+ quay: "quay.io/",
+};
+
+const removeContainerImageVersion = (identifier: string) => {
+ const digestIndex = identifier.indexOf("@");
+ const withoutDigest = digestIndex === -1 ? identifier : identifier.slice(0, digestIndex);
+ const tagIndex = withoutDigest.indexOf(":", withoutDigest.lastIndexOf("/") + 1);
+
+ return tagIndex === -1 ? withoutDigest : withoutDigest.slice(0, tagIndex);
+};
+
+export const normalizeReleaseProviderIdentifier = (provider: ReleaseProviderKind, identifier: string) => {
+ const trimmedIdentifier = identifier.trim();
+ const registryPrefix = releaseProviderRegistryPrefixes[provider];
+ const withoutRegistry =
+ registryPrefix && trimmedIdentifier.startsWith(registryPrefix)
+ ? trimmedIdentifier.slice(registryPrefix.length)
+ : trimmedIdentifier;
+
+ // ponytail: only strip container image versions/digests for providers that use them
+ return registryPrefix ? removeContainerImageVersion(withoutRegistry) : withoutRegistry;
+};
diff --git a/packages/definitions/src/test/release-provider.spec.ts b/packages/definitions/src/test/release-provider.spec.ts
new file mode 100644
index 000000000..7e7e0b90e
--- /dev/null
+++ b/packages/definitions/src/test/release-provider.spec.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizeReleaseProviderIdentifier } from "../release-provider";
+
+describe("normalizeReleaseProviderIdentifier", () => {
+ it("normalizes full ghcr image references", () => {
+ expect(normalizeReleaseProviderIdentifier("gitHubContainerRegistry", "ghcr.io/muchobien/pocketbase:latest")).toBe(
+ "muchobien/pocketbase",
+ );
+ expect(normalizeReleaseProviderIdentifier("gitHubContainerRegistry", "ghcr.io/homarr-labs/homarr:latest")).toBe(
+ "homarr-labs/homarr",
+ );
+ });
+
+ it("keeps existing ghcr owner/name identifiers unchanged", () => {
+ expect(normalizeReleaseProviderIdentifier("gitHubContainerRegistry", "homarr-labs/homarr")).toBe(
+ "homarr-labs/homarr",
+ );
+ });
+
+ it("normalizes container image identifiers for other registries", () => {
+ expect(normalizeReleaseProviderIdentifier("dockerHub", "postgres:17")).toBe("postgres");
+ expect(normalizeReleaseProviderIdentifier("dockerHub", "docker.io/library/postgres:17")).toBe("library/postgres");
+ expect(normalizeReleaseProviderIdentifier("dockerHub", "library/postgres@sha256:abc123")).toBe("library/postgres");
+ expect(normalizeReleaseProviderIdentifier("linuxServerIO", "lscr.io/linuxserver/radarr:latest")).toBe(
+ "linuxserver/radarr",
+ );
+ expect(normalizeReleaseProviderIdentifier("quay", "quay.io/prometheus/prometheus:v3.0.0")).toBe(
+ "prometheus/prometheus",
+ );
+ });
+});
diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts
index 87898a49e..deb953781 100644
--- a/packages/definitions/src/widget-integration-map.ts
+++ b/packages/definitions/src/widget-integration-map.ts
@@ -30,7 +30,6 @@ export const widgetIntegrationSupport: Partial([
- "searchCh",
- "github",
- "dockerHub",
- "gitlab",
- "npm",
- "codeberg",
- "linuxServerIO",
- "gitHubContainerRegistry",
- "quay",
- "mock",
-]);
+export const hiddenFromOnboarding = new Set(["mock"]);
export const defaultBookmarkApps = [
{
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index 5fbaaacf8..db2028082 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -41,6 +41,7 @@ export const widgetKinds = [
"uptimeKuma",
"audioStats",
"umami",
+ "vpn",
"archiveTeamWarrior",
"ups",
"beszelSystemTable",
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx
index 43006ae60..058e75bd0 100644
--- a/packages/forms-collection/src/icon-picker/icon-picker.tsx
+++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx
@@ -55,8 +55,6 @@ export const IconPicker = ({
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
const [query, setQuery] = useState("");
- // ponytail: reset the typed query when the committed value changes
- // externally (form reset, parent re-render) so the input reverts to the URL.
useEffect(() => {
setQuery("");
}, [value]);
@@ -65,13 +63,8 @@ export const IconPicker = ({
const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100);
- // ponytail: only search when the typed query differs from the committed
- // value. On mount the input shows the existing URL (edit mode), which
- // would never match the bare filenames in the icon table.
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
- // ponytail: not useSuspenseQuery — an above Suspense boundary would
- // trigger on every keystroke and the dropdown flash is bad UX.
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText },
{ placeholderData: (prev) => prev },
@@ -123,11 +116,8 @@ export const IconPicker = ({
);
});
- // ponytail: show the typed query if non-empty, else fall back to the URL.
const inputValue = query || value || "";
- // ponytail: show the existing icon as a left-section preview.
const leftSection = shouldShowPreview(value) && ;
- // ponytail: prop override -> real count -> loading text.
const placeholderText =
placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index 6e0c81039..d36be1852 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -27,7 +27,6 @@
"@ctrl/deluge": "catalog:",
"@ctrl/qbittorrent": "catalog:",
"@ctrl/transmission": "catalog:",
- "@gitbeaker/rest": "catalog:",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -39,7 +38,6 @@
"@homarr/validation": "workspace:^0.1.0",
"@immich/sdk": "catalog:",
"@jellyfin/sdk": "catalog:",
- "@octokit/auth-app": "catalog:",
"ical.js": "catalog:",
"maria2": "catalog:",
"octokit": "catalog:",
diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts
index 9d997e7c3..1a84134fc 100644
--- a/packages/integrations/src/base/creator.ts
+++ b/packages/integrations/src/base/creator.ts
@@ -3,12 +3,9 @@ import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
-import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
-import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
-import { DockerHubIntegration } from "../docker-hub/docker-hub-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
@@ -17,16 +14,14 @@ import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integrati
import { SlskdIntegration } from "../download-client/slskd/slskd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
-import { GitHubContainerRegistryIntegration } from "../github-container-registry/github-container-registry-integration";
-import { GithubIntegration } from "../github/github-integration";
-import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { GlancesIntegration } from "../glances/glances-integration";
+import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
+import { GluetunIntegration } from "../gluetun/gluetun-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration";
import { ImmichIntegration } from "../immich/immich-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
-import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration";
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
@@ -35,7 +30,6 @@ import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
-import { NPMIntegration } from "../npm/npm-integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
@@ -48,8 +42,6 @@ import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-in
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
-import { QuayIntegration } from "../quay/quay-integration";
-import { SearchChIntegration } from "../search-ch/search-ch-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
@@ -113,15 +105,7 @@ export const integrationCreators = {
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
opnsense: OPNsenseIntegration,
- github: GithubIntegration,
- dockerHub: DockerHubIntegration,
- gitlab: GitlabIntegration,
- npm: NPMIntegration,
- codeberg: CodebergIntegration,
- linuxServerIO: LinuxServerIOIntegration,
- gitHubContainerRegistry: GitHubContainerRegistryIntegration,
ical: ICalIntegration,
- quay: QuayIntegration,
ntfy: NTFYIntegration,
gotify: GotifyIntegration,
mock: MockIntegration,
@@ -130,13 +114,13 @@ export const integrationCreators = {
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
glances: GlancesIntegration,
- searchCh: SearchChIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
+ gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts
index bf6f3640c..186ed2203 100644
--- a/packages/integrations/src/beszel/beszel-integration.ts
+++ b/packages/integrations/src/beszel/beszel-integration.ts
@@ -13,13 +13,11 @@ import type {
BeszelAlertHistory,
BeszelAuthResponse,
BeszelContainer,
- BeszelContainerStats,
BeszelContainerStatsRecord,
BeszelSmartDevice,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemdService,
- BeszelSystemStats,
BeszelSystemStatsRecord,
CreateAlertInput,
PocketBaseListResponse,
@@ -286,111 +284,6 @@ export class BeszelIntegration extends Integration {
});
}
- public async subscribeRealtimeMetrics(
- systemId: string,
- onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
- signal: AbortSignal,
- ): Promise {
- const session = await this.authenticateAsync();
- const sseUrl = this.url("/api/realtime");
- logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId });
-
- const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
- headers: { Authorization: session.token },
- signal,
- });
-
- if (!sseResponse.ok || !sseResponse.body) {
- logger.warn("Beszel SSE connection failed", {
- integrationId: this.integration.id,
- systemId,
- status: sseResponse.status,
- });
- throw new ResponseError(sseResponse);
- }
- logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId });
-
- const reader = sseResponse.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let clientId: string | null = null;
-
- const processLine = (line: string) => {
- if (!line.startsWith("data:")) return;
- const jsonStr = line.slice(5).trim();
- if (!jsonStr) return;
-
- try {
- const parsed = JSON.parse(jsonStr) as Record;
-
- if (!clientId && typeof parsed.clientId === "string") {
- clientId = parsed.clientId;
- this.subscribeTopic(clientId, systemId, session.token).catch((err) => {
- logger.warn("Failed to subscribe to rt_metrics topic, aborting SSE", { error: err });
- reader.cancel().catch(() => {});
- });
- return;
- }
-
- if ("stats" in parsed) {
- const now = new Date().toISOString();
- const envelope = { id: "", system: systemId, type: "1m" as const, created: now, updated: now };
- const stats: BeszelSystemStatsRecord = { ...envelope, stats: parsed.stats as BeszelSystemStats };
-
- let containerStats: BeszelContainerStatsRecord | null = null;
- const hasContainers =
- parsed.container &&
- Array.isArray(parsed.container) &&
- (parsed.container as BeszelContainerStats[]).length > 0;
- if (hasContainers) {
- containerStats = { ...envelope, stats: parsed.container as BeszelContainerStats[] };
- }
- onMessage({ stats, containerStats });
- }
- } catch {
- logger.debug("Failed to parse SSE message", { line });
- }
- };
-
- try {
- while (!signal.aborted) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split("\n");
- buffer = lines.pop() ?? "";
- for (const line of lines) {
- processLine(line);
- }
- }
- if (!signal.aborted) {
- logger.warn("Beszel SSE stream ended unexpectedly", {
- integrationId: this.integration.id,
- systemId,
- });
- throw new Error("Beszel SSE stream ended unexpectedly");
- }
- } finally {
- reader.cancel().catch(() => {});
- }
- }
-
- private async subscribeTopic(clientId: string, systemId: string, token: string): Promise {
- const topic = `rt_metrics?options=${JSON.stringify({ query: { system: systemId } })}`;
- const response = await fetchWithTrustedCertificatesAsync(this.url("/api/realtime"), {
- method: "POST",
- headers: {
- Authorization: token,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ clientId, subscriptions: [topic] }),
- });
- if (!response.ok) {
- throw new ResponseError(response);
- }
- }
-
protected async testingAsync(input: IntegrationTestingInput): Promise {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
diff --git a/packages/integrations/src/codeberg/codeberg-integration.ts b/packages/integrations/src/codeberg/codeberg-integration.ts
deleted file mode 100644
index 2396a81e0..000000000
--- a/packages/integrations/src/codeberg/codeberg-integration.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-import type { RequestInit, Response } from "undici";
-
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-
-import type { IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import { TestConnectionError } from "../base/test-connection/test-connection-error";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
-import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
-import type {
- DetailsProviderResponse,
- ReleaseResponse,
-} from "../interfaces/releases-providers/releases-providers-types";
-import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas";
-
-const logger = createLogger({ module: "codebergIntegration" });
-
-export class CodebergIntegration extends Integration implements ReleasesProviderIntegration {
- private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise {
- if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
-
- return await callback({
- Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
- });
- }
-
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const response = await this.withHeadersAsync(async (headers) => {
- return await input.fetchAsync(this.url("/version"), {
- headers,
- });
- });
-
- if (!response.ok) {
- return TestConnectionError.StatusResult(response);
- }
-
- return {
- success: true,
- };
- }
-
- private parseIdentifier(identifier: string) {
- const [owner, name] = identifier.split("/");
- if (!owner || !name) {
- logger.warn("Invalid identifier format. Expected 'owner/name', for identifier", {
- identifier,
- });
- return null;
- }
- return { owner, name };
- }
-
- public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise {
- const parsedIdentifier = this.parseIdentifier(identifier);
- if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
-
- const { owner, name } = parsedIdentifier;
-
- const releasesResponse = await this.withHeadersAsync(async (headers) => {
- return await fetchWithTrustedCertificatesAsync(
- this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`),
- { headers },
- );
- });
- if (!releasesResponse.ok) {
- return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
- }
-
- const releasesResponseJson: unknown = await releasesResponse.json();
- const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
- if (!success) {
- return {
- success: false,
- error: {
- code: "unexpected",
- message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message,
- },
- };
- }
-
- const formattedReleases = data.map((tag) => ({
- latestRelease: tag.tag_name,
- latestReleaseAt: tag.published_at,
- releaseUrl: tag.url,
- releaseDescription: tag.body,
- isPreRelease: tag.prerelease,
- }));
-
- const latestRelease = getLatestRelease(formattedReleases, versionRegex);
- if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
-
- const details = await this.getDetailsAsync(owner, name);
-
- return { success: true, data: { ...details, ...latestRelease } };
- }
-
- protected async getDetailsAsync(owner: string, name: string): Promise {
- const response = await this.withHeadersAsync(async (headers) => {
- return await fetchWithTrustedCertificatesAsync(
- this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
- {
- headers,
- },
- );
- });
-
- if (!response.ok) {
- logger.warn("Failed to get details", {
- owner,
- name,
- error: response.statusText,
- });
-
- return undefined;
- }
-
- const responseJson = await response.json();
- const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
-
- if (!success) {
- logger.warn("Failed to parse details", {
- owner,
- name,
- error,
- });
-
- return undefined;
- }
-
- return {
- projectUrl: data.html_url,
- projectDescription: data.description,
- isFork: data.fork,
- isArchived: data.archived,
- createdAt: data.created_at,
- starsCount: data.stars_count,
- openIssues: data.open_issues_count,
- forksCount: data.forks_count,
- };
- }
-}
diff --git a/packages/integrations/src/codeberg/codeberg-schemas.ts b/packages/integrations/src/codeberg/codeberg-schemas.ts
deleted file mode 100644
index ff6e62a01..000000000
--- a/packages/integrations/src/codeberg/codeberg-schemas.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { z } from "zod/v4";
-
-export const releasesResponseSchema = z.array(
- z.object({
- tag_name: z.string(),
- published_at: z.string().transform((value) => new Date(value)),
- url: z.string(),
- body: z.string(),
- prerelease: z.boolean(),
- }),
-);
-
-export const detailsResponseSchema = z.object({
- html_url: z.string(),
- description: z.string(),
- fork: z.boolean(),
- archived: z.boolean(),
- created_at: z.string().transform((value) => new Date(value)),
- stars_count: z.number(),
- open_issues_count: z.number(),
- forks_count: z.number(),
-});
diff --git a/packages/integrations/src/docker-hub/docker-hub-integration.ts b/packages/integrations/src/docker-hub/docker-hub-integration.ts
deleted file mode 100644
index b9e9ea09c..000000000
--- a/packages/integrations/src/docker-hub/docker-hub-integration.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import type { fetch, RequestInit, Response } from "undici";
-
-import { ResponseError } from "@homarr/common/server";
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-
-import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import type { SessionStore } from "../base/session-store";
-import { createSessionStore } from "../base/session-store";
-import { TestConnectionError } from "../base/test-connection/test-connection-error";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
-import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
-import type {
- DetailsProviderResponse,
- ReleaseResponse,
-} from "../interfaces/releases-providers/releases-providers-types";
-import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas";
-
-const logger = createLogger({ module: "dockerHubIntegration" });
-
-export class DockerHubIntegration extends Integration implements ReleasesProviderIntegration {
- private readonly sessionStore: SessionStore;
-
- constructor(integration: IntegrationInput) {
- super(integration);
- this.sessionStore = createSessionStore(integration);
- }
-
- private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise {
- if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken"))
- return await callback(undefined);
-
- const storedSession = await this.sessionStore.getAsync();
-
- if (storedSession) {
- logger.debug("Using stored session for request", { integrationId: this.integration.id });
- const response = await callback({
- Authorization: `Bearer ${storedSession}`,
- });
- if (response.status !== 401) {
- return response;
- }
-
- logger.debug("Session expired, getting new session", { integrationId: this.integration.id });
- }
-
- const accessToken = await this.getSessionAsync();
- await this.sessionStore.setAsync(accessToken);
- return await callback({
- Authorization: `Bearer ${accessToken}`,
- });
- }
-
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const hasAuth = this.hasSecretValue("username") && this.hasSecretValue("personalAccessToken");
-
- if (hasAuth) {
- logger.debug("Testing DockerHub connection with authentication", { integrationId: this.integration.id });
- await this.getSessionAsync(input.fetchAsync);
- } else {
- logger.debug("Testing DockerHub connection without authentication", { integrationId: this.integration.id });
- const response = await input.fetchAsync(this.url("/v2/repositories/library"));
- if (!response.ok) {
- return TestConnectionError.StatusResult(response);
- }
- }
-
- return {
- success: true,
- };
- }
-
- private parseIdentifier(identifier: string) {
- if (!identifier.includes("/")) return { owner: "", name: identifier };
- const [owner, name] = identifier.split("/");
- if (!owner || !name) {
- logger.warn("Invalid identifier format. Expected 'owner/name' or 'name', for identifier", {
- identifier,
- });
- return null;
- }
- return { owner, name };
- }
-
- public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise {
- const parsedIdentifier = this.parseIdentifier(identifier);
- if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
-
- const { owner, name } = parsedIdentifier;
-
- const relativeUrl: `/${string}` = owner
- ? `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`
- : `/v2/repositories/library/${encodeURIComponent(name)}`;
-
- for (let page = 0; page <= 5; page++) {
- const releasesResponse = await this.withHeadersAsync(async (headers) => {
- return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100&page=${page}`), {
- headers,
- });
- });
- if (!releasesResponse.ok) {
- return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
- }
-
- const releasesResponseJson: unknown = await releasesResponse.json();
- const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson);
- if (!releasesResult.success) {
- return {
- success: false,
- error: {
- code: "unexpected",
- message: releasesResponseJson
- ? JSON.stringify(releasesResponseJson, null, 2)
- : releasesResult.error.message,
- },
- };
- }
-
- const latestRelease = getLatestRelease(releasesResult.data.results, versionRegex);
- if (!latestRelease) continue;
-
- const details = await this.getDetailsAsync(relativeUrl);
-
- return { success: true, data: { ...details, ...latestRelease } };
- }
-
- return { success: false, error: { code: "noMatchingVersion" } };
- }
-
- private async getDetailsAsync(relativeUrl: `/${string}`): Promise {
- const response = await this.withHeadersAsync(async (headers) => {
- return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/`), {
- headers,
- });
- });
-
- if (!response.ok) {
- logger.warn("Failed to get details response", {
- relativeUrl,
- error: response.statusText,
- });
-
- return undefined;
- }
-
- const responseJson = await response.json();
- const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
-
- if (!success) {
- logger.warn("Failed to parse details response", {
- relativeUrl,
- error,
- });
-
- return undefined;
- }
-
- return {
- projectUrl: `https://hub.docker.com/r/${data.namespace === "library" ? "_" : data.namespace}/${data.name}`,
- projectDescription: data.description,
- createdAt: data.date_registered,
- starsCount: data.star_count,
- };
- }
-
- private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise {
- const response = await fetchAsync(this.url("/v2/auth/token"), {
- method: "POST",
- body: JSON.stringify({
- identifier: this.getSecretValue("username"),
- secret: this.getSecretValue("personalAccessToken"),
- }),
- });
-
- if (!response.ok) throw new ResponseError(response);
-
- const data = await response.json();
- const result = await accessTokenResponseSchema.parseAsync(data);
-
- if (!result.access_token) {
- throw new ResponseError({ status: 401, url: response.url });
- }
-
- logger.info("Received session successfully", { integrationId: this.integration.id });
-
- return result.access_token;
- }
-}
diff --git a/packages/integrations/src/docker-hub/docker-hub-schemas.ts b/packages/integrations/src/docker-hub/docker-hub-schemas.ts
deleted file mode 100644
index 674ae5532..000000000
--- a/packages/integrations/src/docker-hub/docker-hub-schemas.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { z } from "zod/v4";
-
-export const accessTokenResponseSchema = z.object({
- access_token: z.string(),
-});
-
-export const releasesResponseSchema = z.object({
- results: z.array(
- z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({
- latestRelease: tag.name,
- latestReleaseAt: tag.last_updated,
- })),
- ),
-});
-
-export const detailsResponseSchema = z.object({
- name: z.string(),
- namespace: z.string(),
- description: z.string(),
- star_count: z.number(),
- date_registered: z.string().transform((value) => new Date(value)),
-});
diff --git a/packages/integrations/src/github-container-registry/github-container-registry-integration.ts b/packages/integrations/src/github-container-registry/github-container-registry-integration.ts
deleted file mode 100644
index 1400551a8..000000000
--- a/packages/integrations/src/github-container-registry/github-container-registry-integration.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { createAppAuth } from "@octokit/auth-app";
-import { Octokit, RequestError } from "octokit";
-import type { fetch } from "undici";
-
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-
-import { HandleIntegrationErrors } from "../base/errors/decorator";
-import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
-import type { IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
-import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
-import type {
- DetailsProviderResponse,
- ReleaseProviderResponse,
- ReleaseResponse,
-} from "../interfaces/releases-providers/releases-providers-types";
-
-const logger = createLogger({ module: "githubContainerRegistryIntegration" });
-
-@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
-export class GitHubContainerRegistryIntegration extends Integration implements ReleasesProviderIntegration {
- private static readonly userAgent = "Homarr-Lab/Homarr:GitHubContainerRegistryIntegration";
-
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const api = this.getApi(input.fetchAsync);
-
- if (this.hasSecretValue("personalAccessToken")) {
- await api.rest.users.getAuthenticated();
- } else if (this.hasSecretValue("githubAppId")) {
- await api.rest.apps.getInstallation({
- installation_id: Number(this.getSecretValue("githubInstallationId")),
- });
- } else {
- await api.request("GET /octocat");
- }
-
- return {
- success: true,
- };
- }
-
- private parseIdentifier(identifier: string) {
- const [owner, name] = identifier.split("/");
- if (!owner || !name) {
- logger.warn("Invalid identifier format. Expected 'owner/name', for identifier", { identifier });
- return null;
- }
- return { owner, name };
- }
-
- public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise {
- const parsedIdentifier = this.parseIdentifier(identifier);
- if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
-
- const { owner, name } = parsedIdentifier;
- const api = this.getApi();
-
- try {
- const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({
- username: owner,
- package_type: "container",
- package_name: name,
- per_page: 100,
- });
-
- const releasesProviderResponse = releasesResponse.data.reduce((acc, release) => {
- if (!release.metadata?.container?.tags || !(release.metadata.container.tags.length > 0)) return acc;
-
- release.metadata.container.tags.forEach((tag) => {
- acc.push({
- latestRelease: tag,
- latestReleaseAt: new Date(release.updated_at),
- releaseUrl: release.html_url,
- releaseDescription: release.description ?? undefined,
- });
- });
- return acc;
- }, []);
-
- const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
- if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
-
- const details = await this.getDetailsAsync(api, owner, name);
-
- return { success: true, data: { ...details, ...latestRelease } };
- } catch (error) {
- const errorMessage = error instanceof RequestError ? error.message : String(error);
- logger.warn("Failed to get releases", {
- owner,
- name,
- error: errorMessage,
- });
- return { success: false, error: { code: "unexpected", message: errorMessage } };
- }
- }
-
- protected async getDetailsAsync(
- api: Octokit,
- owner: string,
- name: string,
- ): Promise {
- try {
- const response = await api.rest.packages.getPackageForUser({
- username: owner,
- package_type: "container",
- package_name: name,
- });
-
- return {
- projectUrl: response.data.repository?.html_url ?? response.data.html_url,
- projectDescription: response.data.repository?.description ?? undefined,
- isFork: response.data.repository?.fork,
- isArchived: response.data.repository?.archived,
- createdAt: new Date(response.data.created_at),
- starsCount: response.data.repository?.stargazers_count,
- openIssues: response.data.repository?.open_issues_count,
- forksCount: response.data.repository?.forks_count,
- };
- } catch (error) {
- logger.warn("Failed to get details", {
- owner,
- name,
- error: error instanceof RequestError ? error.message : String(error),
- });
- return undefined;
- }
- }
-
- private getAuthProperties(): Pick {
- if (this.hasSecretValue("personalAccessToken"))
- return {
- auth: this.getSecretValue("personalAccessToken"),
- };
-
- if (this.hasSecretValue("githubAppId"))
- return {
- authStrategy: createAppAuth,
- auth: {
- appId: this.getSecretValue("githubAppId"),
- installationId: this.getSecretValue("githubInstallationId"),
- privateKey: this.getSecretValue("privateKey"),
- } satisfies Parameters[0],
- };
-
- return {};
- }
-
- private getApi(customFetch?: typeof fetch) {
- return new Octokit({
- baseUrl: this.url("/").origin,
- request: {
- fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
- },
- userAgent: GitHubContainerRegistryIntegration.userAgent,
- // Disable throttling for this integration, Octokit will retry by default after a set time,
- // thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
- throttle: { enabled: false },
- ...this.getAuthProperties(),
- });
- }
-}
-
-type OctokitOptions = Exclude[0], undefined>;
diff --git a/packages/integrations/src/github/github-integration.ts b/packages/integrations/src/github/github-integration.ts
deleted file mode 100644
index 01548ecc6..000000000
--- a/packages/integrations/src/github/github-integration.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { createAppAuth } from "@octokit/auth-app";
-import { Octokit, RequestError as OctokitRequestError } from "octokit";
-import type { fetch } from "undici";
-
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-
-import { HandleIntegrationErrors } from "../base/errors/decorator";
-import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
-import type { IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
-import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
-import type {
- DetailsProviderResponse,
- ReleaseProviderResponse,
- ReleaseResponse,
-} from "../interfaces/releases-providers/releases-providers-types";
-
-const logger = createLogger({ module: "githubIntegration" });
-
-@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
-export class GithubIntegration extends Integration implements ReleasesProviderIntegration {
- private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration";
-
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const api = this.getApi(input.fetchAsync);
-
- if (this.hasSecretValue("personalAccessToken")) {
- await api.rest.users.getAuthenticated();
- } else if (this.hasSecretValue("githubAppId")) {
- await api.rest.apps.getInstallation({
- installation_id: Number(this.getSecretValue("githubInstallationId")),
- });
- } else {
- await api.request("GET /octocat");
- }
-
- return {
- success: true,
- };
- }
-
- private parseIdentifier(identifier: string) {
- const [owner, name] = identifier.split("/");
- if (!owner || !name) {
- logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", {
- identifier,
- });
- return null;
- }
- return { owner, name };
- }
-
- public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise {
- const parsedIdentifier = this.parseIdentifier(identifier);
- if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
-
- const { owner, name } = parsedIdentifier;
- const api = this.getApi();
-
- try {
- const releasesResponse = await api.rest.repos.listReleases({ owner, repo: name });
-
- if (releasesResponse.data.length === 0) {
- logger.warn("No releases found", {
- identifier: `${owner}/${name}`,
- });
- return { success: false, error: { code: "noMatchingVersion" } };
- }
-
- const releasesProviderResponse = releasesResponse.data.reduce((acc, release) => {
- if (!release.published_at) return acc;
-
- acc.push({
- latestRelease: release.tag_name,
- latestReleaseAt: new Date(release.published_at),
- releaseUrl: release.html_url,
- releaseDescription: release.body ?? undefined,
- isPreRelease: release.prerelease,
- });
- return acc;
- }, []);
-
- const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
- if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
-
- const details = await this.getDetailsAsync(api, owner, name);
-
- return { success: true, data: { ...details, ...latestRelease } };
- } catch (error) {
- const errorMessage = error instanceof OctokitRequestError ? error.message : String(error);
- logger.warn("Failed to get releases", {
- owner,
- name,
- error: errorMessage,
- });
- return { success: false, error: { code: "unexpected", message: errorMessage } };
- }
- }
-
- protected async getDetailsAsync(
- api: Octokit,
- owner: string,
- name: string,
- ): Promise {
- try {
- const response = await api.rest.repos.get({
- owner,
- repo: name,
- });
-
- return {
- projectUrl: response.data.html_url,
- projectDescription: response.data.description ?? undefined,
- isFork: response.data.fork,
- isArchived: response.data.archived,
- createdAt: new Date(response.data.created_at),
- starsCount: response.data.stargazers_count,
- openIssues: response.data.open_issues_count,
- forksCount: response.data.forks_count,
- };
- } catch (error) {
- logger.warn("Failed to get details", {
- owner,
- name,
- error: error instanceof OctokitRequestError ? error.message : String(error),
- });
- return undefined;
- }
- }
-
- private getAuthProperties(): Pick {
- if (this.hasSecretValue("personalAccessToken"))
- return {
- auth: this.getSecretValue("personalAccessToken"),
- };
-
- if (this.hasSecretValue("githubAppId"))
- return {
- authStrategy: createAppAuth,
- auth: {
- appId: this.getSecretValue("githubAppId"),
- installationId: this.getSecretValue("githubInstallationId"),
- privateKey: this.getSecretValue("privateKey"),
- } satisfies Parameters[0],
- };
-
- return {};
- }
-
- private getApi(customFetch?: typeof fetch) {
- return new Octokit({
- baseUrl: this.url("/").origin,
- request: {
- fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
- },
- userAgent: GithubIntegration.userAgent,
- // Disable throttling for this integration, Octokit will retry by default after a set time,
- // thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
- throttle: { enabled: false },
- ...this.getAuthProperties(),
- });
- }
-}
-
-type OctokitOptions = Exclude[0], undefined>;
diff --git a/packages/integrations/src/gitlab/gitlab-integration.ts b/packages/integrations/src/gitlab/gitlab-integration.ts
deleted file mode 100644
index fd1ebc926..000000000
--- a/packages/integrations/src/gitlab/gitlab-integration.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import type { Gitlab as CoreGitlab } from "@gitbeaker/core";
-import { createRequesterFn, defaultOptionsHandler } from "@gitbeaker/requester-utils";
-import type { FormattedResponse, RequestOptions, ResourceOptions } from "@gitbeaker/requester-utils";
-import { Gitlab } from "@gitbeaker/rest";
-
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-
-import type { IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import { TestConnectionError } from "../base/test-connection/test-connection-error";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
-import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
-import type {
- DetailsProviderResponse,
- ReleaseProviderResponse,
- ReleaseResponse,
-} from "../interfaces/releases-providers/releases-providers-types";
-
-const logger = createLogger({ module: "gitlabIntegration" });
-
-export class GitlabIntegration extends Integration implements ReleasesProviderIntegration {
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const response = await input.fetchAsync(this.url("/api/v4/projects"), {
- headers: {
- ...(this.hasSecretValue("personalAccessToken")
- ? { Authorization: `Bearer ${this.getSecretValue("personalAccessToken")}` }
- : {}),
- },
- });
-
- if (!response.ok) {
- return TestConnectionError.StatusResult(response);
- }
-
- return {
- success: true,
- };
- }
-
- public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise {
- const api = this.getApi();
-
- try {
- const releasesResponse = await api.ProjectReleases.all(identifier, {
- perPage: 100,
- });
-
- if (releasesResponse instanceof Error) {
- logger.warn("No releases found", {
- identifier,
- error: releasesResponse.message,
- });
- return { success: false, error: { code: "noReleasesFound" } };
- }
-
- const releasesProviderResponse = releasesResponse.reduce((acc, release) => {
- if (!release.released_at) return acc;
-
- const releaseDate = new Date(release.released_at);
-
- acc.push({
- latestRelease: release.name ?? release.tag_name,
- latestReleaseAt: releaseDate,
- releaseUrl: release._links.self,
- releaseDescription: release.description ?? undefined,
- isPreRelease: releaseDate > new Date(), // For upcoming releases the `released_at` will be set to the future (https://docs.gitlab.com/api/releases/#upcoming-releases). Gitbreaker doesn't currently support the `upcoming_release` field (https://github.com/jdalrymple/gitbeaker/issues/3730)
- });
- return acc;
- }, []);
-
- const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
- if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
-
- const details = await this.getDetailsAsync(api, identifier);
-
- return { success: true, data: { ...details, ...latestRelease } };
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- logger.warn("Failed to get releases", {
- identifier,
- error: errorMessage,
- });
- return { success: false, error: { code: "unexpected", message: errorMessage } };
- }
- }
-
- protected async getDetailsAsync(api: CoreGitlab, identifier: string): Promise {
- try {
- const response = await api.Projects.show(identifier);
-
- if (response instanceof Error) {
- logger.warn("Failed to get details", {
- identifier,
- error: response.message,
- });
-
- return undefined;
- }
-
- if (!response.web_url) {
- logger.warn("No web URL found", {
- identifier,
- });
- return undefined;
- }
-
- return {
- projectUrl: response.web_url,
- projectDescription: response.description ?? undefined,
- isFork: response.forked_from_project !== null,
- isArchived: response.archived,
- createdAt: new Date(response.created_at),
- starsCount: response.star_count,
- openIssues: response.open_issues_count,
- forksCount: response.forks_count,
- };
- } catch (error) {
- logger.warn("Failed to get details", {
- identifier,
- error: error instanceof Error ? error.message : String(error),
- });
-
- return undefined;
- }
- }
-
- private getApi() {
- return new Gitlab({
- host: this.url("/").origin,
- requesterFn: createRequesterFn(
- async (serviceOptions: ResourceOptions, _: RequestOptions) => await defaultOptionsHandler(serviceOptions),
- async (endpoint: string, options?: Record): Promise => {
- if (options === undefined) {
- throw new Error("Gitlab library is not configured correctly. Options must be provided.");
- }
-
- const response = await fetchWithTrustedCertificatesAsync(
- `${options.prefixUrl as string}${endpoint}`,
- options,
- );
- const headers = Object.fromEntries(response.headers.entries());
-
- return {
- status: response.status,
- headers,
- body: await response.json(),
- } as FormattedResponse;
- },
- ),
- ...(this.hasSecretValue("personalAccessToken") ? { token: this.getSecretValue("personalAccessToken") } : {}),
- });
- }
-}
diff --git a/packages/integrations/src/glances/glances-integration.ts b/packages/integrations/src/glances/glances-integration.ts
index 6364ebcb7..5915b44b2 100644
--- a/packages/integrations/src/glances/glances-integration.ts
+++ b/packages/integrations/src/glances/glances-integration.ts
@@ -35,7 +35,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
if (session == null) throw new Error("Session was unexpectitly null");
- const stats = await this.getAllStatsAsync();
+ const [stats, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]);
return {
cpuUtilization: stats.cpu.total,
@@ -58,7 +58,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
cpuModelName: stats.quicklook?.cpu_name ?? "Unknown",
loadAverage: null,
smart: [],
- cpuTemp: undefined,
+ cpuTemp,
gpu: stats.gpu.map((gpu) => ({
gpuId: gpu.gpu_id,
name: gpu.name,
@@ -80,6 +80,23 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
return await response.text();
}
+ private async getCpuTempAsync(): Promise {
+ // CPU temperature is optional data, so a transport error or payload drift on
+ // /api/4/sensors must not fail the whole getSystemInfoAsync() call.
+ try {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/sensors"));
+
+ if (!response.ok) {
+ return undefined;
+ }
+
+ const sensors = await sensorsSchema.parseAsync(await response.json());
+ return parseGlancesCpuTempFromSensors(sensors);
+ } catch {
+ return undefined;
+ }
+ }
+
private async getAllStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all"));
@@ -173,3 +190,33 @@ const allSchema = z.object({
)
.default([]),
});
+
+const sensorsSchema = z.array(
+ z.object({
+ label: z.string(),
+ value: z.number(),
+ type: z.string().optional(),
+ }),
+);
+
+const cpuTempLabelPriority = ["CPU", "Package id 0"] as const;
+
+export const parseGlancesCpuTempFromSensors = (sensors: z.infer): number | undefined => {
+ const temperatureSensors = sensors.filter(
+ (sensor) => sensor.type === "temperature_core" || sensor.type === "temperature",
+ );
+
+ for (const label of cpuTempLabelPriority) {
+ const match = temperatureSensors.find((sensor) => sensor.label === label);
+ if (match) {
+ return match.value;
+ }
+ }
+
+ const firstCore = temperatureSensors[0];
+ if (firstCore) {
+ return firstCore.value;
+ }
+
+ return undefined;
+};
diff --git a/packages/integrations/src/gluetun/gluetun-integration.ts b/packages/integrations/src/gluetun/gluetun-integration.ts
new file mode 100644
index 000000000..a150f0f18
--- /dev/null
+++ b/packages/integrations/src/gluetun/gluetun-integration.ts
@@ -0,0 +1,115 @@
+import { ResponseError } from "@homarr/common/server";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationTestingInput } from "../base/integration";
+import { Integration } from "../base/integration";
+import { TestConnectionError } from "../base/test-connection/test-connection-error";
+import type { TestingResult } from "../base/test-connection/test-connection-service";
+import type { VpnSummaryIntegration } from "../interfaces/vpn-summary/vpn-summary-integration";
+import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
+import {
+ gluetunDnsStatusSchema,
+ gluetunPublicIpSchema,
+ gluetunVpnSettingsSchema,
+ gluetunVpnStatusSchema,
+} from "./gluetun-types";
+import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
+
+export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ const authHeaders = this.getAuthHeaders();
+ const url = this.url("/v1/vpn/status");
+ const response = await input.fetchAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) {
+ return TestConnectionError.StatusResult(response);
+ }
+
+ return { success: true };
+ }
+
+ public async getSummaryAsync(): Promise {
+ const authHeaders = this.getAuthHeaders();
+ const [vpnStatus, dnsStatus, publicIp, vpnSettings] = await Promise.all([
+ this.getVpnStatusAsync(authHeaders),
+ this.getDnsStatusAsync(authHeaders),
+ this.getPublicIpAsync(authHeaders),
+ this.getVpnSettingAsync(authHeaders),
+ ]);
+
+ return {
+ vpnStatus: vpnStatus.status,
+ dnsStatus: dnsStatus.status,
+ publicIp: publicIp.public_ip,
+ country: publicIp.country,
+ city: publicIp.city,
+ vpnProvider: {
+ protocol: vpnSettings.type,
+ provider: vpnSettings.provider.name,
+ },
+ };
+ }
+
+ private async getVpnStatusAsync(authHeaders: Record): Promise {
+ const url = this.url("/v1/vpn/status");
+
+ const response = await fetchWithTrustedCertificatesAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) throw new ResponseError(response);
+ return gluetunVpnStatusSchema.parse(await response.json());
+ }
+
+ private async getDnsStatusAsync(authHeaders: Record): Promise {
+ const url = this.url("/v1/dns/status");
+
+ const response = await fetchWithTrustedCertificatesAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) throw new ResponseError(response);
+ return gluetunDnsStatusSchema.parse(await response.json());
+ }
+
+ private async getPublicIpAsync(authHeaders: Record): Promise {
+ const url = this.url("/v1/publicip/ip");
+
+ const response = await fetchWithTrustedCertificatesAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) throw new ResponseError(response);
+ return gluetunPublicIpSchema.parse(await response.json());
+ }
+
+ private async getVpnSettingAsync(authHeaders: Record): Promise {
+ const url = this.url("/v1/vpn/settings");
+
+ const response = await fetchWithTrustedCertificatesAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) throw new ResponseError(response);
+ return gluetunVpnSettingsSchema.parse(await response.json());
+ }
+
+ private getAuthHeaders(): Record {
+ if (this.hasSecretValue("apiKey")) {
+ return { "X-API-Key": this.getSecretValue("apiKey") };
+ }
+
+ if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
+ const username = this.getSecretValue("username");
+ const password = this.getSecretValue("password");
+
+ const encodedCredentials = btoa(`${username}:${password}`);
+
+ return { Authorization: `Basic ${encodedCredentials}` };
+ }
+
+ return {};
+ }
+}
diff --git a/packages/integrations/src/gluetun/gluetun-types.ts b/packages/integrations/src/gluetun/gluetun-types.ts
new file mode 100644
index 000000000..6886d72a7
--- /dev/null
+++ b/packages/integrations/src/gluetun/gluetun-types.ts
@@ -0,0 +1,116 @@
+import { z } from "zod/v4";
+
+export const gluetunVpnStatusSchema = z.object({
+ status: z.string(),
+});
+
+export type GluetunVpnStatus = z.infer;
+
+export const gluetunDnsStatusSchema = gluetunVpnStatusSchema;
+
+export type GluetunDnsStatus = z.infer;
+
+export const gluetunPublicIpSchema = z.object({
+ public_ip: z.string(),
+ region: z.string(),
+ country: z.string(),
+ city: z.string(),
+ location: z.string(),
+ organization: z.string(),
+ postal_code: z.string(),
+ timezone: z.string(),
+});
+
+export type GluetunPublicIp = z.infer;
+
+const gluetunServerSelectionOpenvpnSchema = z.object({
+ config_file_path: z.string(),
+ protocol: z.string(),
+ endpoint_ip: z.string(),
+ custom_port: z.number(),
+ pia_encryption_preset: z.string(),
+});
+
+const gluetunServerSelectionWireguardSchema = z.object({
+ endpoint_ip: z.string(),
+ endpoint_port: z.number(),
+ public_key: z.string(),
+});
+
+const gluetunServerSelectionSchema = z.object({
+ vpn: z.string(),
+ countries: z.array(z.string()).nullable(),
+ categories: z.array(z.string()).nullable(),
+ regions: z.array(z.string()).nullable(),
+ cities: z.array(z.string()).nullable(),
+ isps: z.array(z.string()).nullable(),
+ names: z.array(z.string()).nullable(),
+ numbers: z.array(z.number()).nullable(),
+ hostnames: z.array(z.string()),
+ owned_only: z.boolean(),
+ free_only: z.boolean(),
+ premium_only: z.boolean(),
+ stream_only: z.boolean(),
+ multi_hop_only: z.boolean(),
+ port_forward_only: z.boolean(),
+ secure_core_only: z.boolean(),
+ tor_only: z.boolean(),
+ openvpn: gluetunServerSelectionOpenvpnSchema,
+ wireguard: gluetunServerSelectionWireguardSchema,
+});
+
+const gluetunPortForwardingSchema = z.object({
+ enabled: z.boolean(),
+ provider: z.string(),
+ status_file_path: z.string(),
+ up_command: z.string(),
+ down_command: z.string(),
+ listening_port: z.number(),
+ username: z.string(),
+ password: z.string(),
+});
+
+const gluetunProviderSchema = z.object({
+ name: z.string(),
+ server_selection: gluetunServerSelectionSchema,
+ port_forwarding: gluetunPortForwardingSchema,
+});
+
+const gluetunOpenvpnSettingsSchema = z.object({
+ version: z.string(),
+ user: z.string(),
+ password: z.string(),
+ config_file_path: z.string(),
+ ciphers: z.array(z.string()).nullable(),
+ auth: z.string(),
+ cert: z.string(),
+ key: z.string(),
+ encrypted_key: z.string(),
+ key_passphrase: z.string(),
+ pia_encryption_preset: z.string(),
+ mssfix: z.number(),
+ interface: z.string(),
+ process_user: z.string(),
+ verbosity: z.number(),
+ flags: z.array(z.string()).nullable(),
+});
+
+const gluetunWireguardSettingsSchema = z.object({
+ private_key: z.string(),
+ pre_shared_key: z.string(),
+ addresses: z.array(z.string()),
+ allowed_ips: z.array(z.string()),
+ interface: z.string(),
+ persistent_keep_alive_interval: z.number(),
+ mtu: z.number(),
+ implementation: z.string(),
+});
+
+export const gluetunVpnSettingsSchema = z.object({
+ type: z.string(),
+ provider: gluetunProviderSchema,
+ openvpn: gluetunOpenvpnSettingsSchema,
+ wireguard: gluetunWireguardSettingsSchema,
+});
+
+export type GluetunVpnSettings = z.infer;
diff --git a/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts b/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts
new file mode 100644
index 000000000..1f84005f3
--- /dev/null
+++ b/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts
@@ -0,0 +1,371 @@
+import { Response } from "undici";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { createDb } from "@homarr/db/test";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationTestingInput } from "../../base/integration";
+import { GluetunIntegration } from "../gluetun-integration";
+import {
+ gluetunDnsStatusSchema,
+ gluetunPublicIpSchema,
+ gluetunVpnSettingsSchema,
+ gluetunVpnStatusSchema,
+} from "../gluetun-types";
+
+vi.mock("@homarr/db", async (importActual) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
+ const actual = await importActual();
+ return {
+ ...actual,
+ db: createDb(),
+ };
+});
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
+
+const TEST_URL = "https://gluetun.example.com";
+const TEST_API_KEY = "test-api-key";
+const TEST_USERNAME = "user";
+const TEST_PASSWORD = "p@ss";
+
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+
+type FetchResponse = Awaited>;
+
+const VPN_STATUS_PAYLOAD = { status: "running" };
+const DNS_STATUS_PAYLOAD = { status: "running" };
+const PUBLIC_IP_PAYLOAD = {
+ public_ip: "203.0.113.42",
+ region: "Europe",
+ country: "Germany",
+ city: "Berlin",
+ location: "52.5200,13.4050",
+ organization: "Example Org",
+ postal_code: "10115",
+ timezone: "Europe/Berlin",
+};
+const VPN_SETTINGS_PAYLOAD = {
+ type: "wireguard",
+ provider: {
+ name: "mullvad",
+ server_selection: {
+ vpn: "wireguard",
+ countries: ["Germany"],
+ categories: null,
+ regions: null,
+ cities: ["Berlin"],
+ isps: null,
+ names: null,
+ numbers: null,
+ hostnames: ["de-ber-wg-001"],
+ owned_only: false,
+ free_only: false,
+ premium_only: false,
+ stream_only: false,
+ multi_hop_only: false,
+ port_forward_only: false,
+ secure_core_only: false,
+ tor_only: false,
+ openvpn: {
+ config_file_path: "",
+ protocol: "udp",
+ endpoint_ip: "",
+ custom_port: 0,
+ pia_encryption_preset: "",
+ },
+ wireguard: {
+ endpoint_ip: "203.0.113.1",
+ endpoint_port: 51820,
+ public_key: "pubkey",
+ },
+ },
+ port_forwarding: {
+ enabled: false,
+ provider: "mullvad",
+ status_file_path: "",
+ up_command: "",
+ down_command: "",
+ listening_port: 0,
+ username: "",
+ password: "",
+ },
+ },
+ openvpn: {
+ version: "",
+ user: "",
+ password: "",
+ config_file_path: "",
+ ciphers: null,
+ auth: "",
+ cert: "",
+ key: "",
+ encrypted_key: "",
+ key_passphrase: "",
+ pia_encryption_preset: "",
+ mssfix: 0,
+ interface: "tun0",
+ process_user: "",
+ verbosity: 0,
+ flags: null,
+ },
+ wireguard: {
+ private_key: "",
+ pre_shared_key: "",
+ addresses: ["10.0.0.2/32"],
+ allowed_ips: ["0.0.0.0/0"],
+ interface: "wg0",
+ persistent_keep_alive_interval: 25,
+ mtu: 1420,
+ implementation: "auto",
+ },
+};
+
+const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
+ new Response(JSON.stringify(body), {
+ ...init,
+ headers: { "content-type": "application/json" },
+ }) as unknown as FetchResponse;
+
+const setupHappyPath = () => {
+ mockFetch.mockImplementation((url) => {
+ const urlString = typeof url === "string" ? url : url.toString();
+ const { pathname } = new URL(urlString);
+
+ switch (pathname) {
+ case "/v1/vpn/status":
+ return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
+ case "/v1/dns/status":
+ return Promise.resolve(jsonResponse(DNS_STATUS_PAYLOAD));
+ case "/v1/publicip/ip":
+ return Promise.resolve(jsonResponse(PUBLIC_IP_PAYLOAD));
+ case "/v1/vpn/settings":
+ return Promise.resolve(jsonResponse(VPN_SETTINGS_PAYLOAD));
+ default:
+ return Promise.resolve(jsonResponse({ error: "not found" }, { status: 404 }));
+ }
+ });
+};
+
+const createIntegrationWithApiKey = () =>
+ new GluetunIntegration({
+ id: "gluetun-1",
+ name: "Gluetun",
+ url: TEST_URL,
+ externalUrl: null,
+ decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
+ });
+
+const createIntegrationWithBasicAuth = () =>
+ new GluetunIntegration({
+ id: "gluetun-2",
+ name: "Gluetun",
+ url: TEST_URL,
+ externalUrl: null,
+ decryptedSecrets: [
+ { kind: "username", value: TEST_USERNAME },
+ { kind: "password", value: TEST_PASSWORD },
+ ],
+ });
+
+describe("GluetunIntegration.getSummaryAsync", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("maps the four upstream responses into a flat VPN summary", async () => {
+ setupHappyPath();
+
+ const result = await createIntegrationWithApiKey().getSummaryAsync();
+
+ expect(result).toEqual({
+ vpnStatus: "running",
+ dnsStatus: "running",
+ publicIp: "203.0.113.42",
+ country: "Germany",
+ city: "Berlin",
+ vpnProvider: {
+ protocol: "wireguard",
+ provider: "mullvad",
+ },
+ });
+ });
+
+ test("hits each of the four control endpoints exactly once", async () => {
+ setupHappyPath();
+
+ await createIntegrationWithApiKey().getSummaryAsync();
+
+ const calledPaths = mockFetch.mock.calls.map(([url]) => {
+ const urlString = typeof url === "string" ? url : url.toString();
+ return new URL(urlString).pathname;
+ });
+
+ expect(calledPaths).toHaveLength(4);
+ expect(new Set(calledPaths)).toEqual(
+ new Set(["/v1/vpn/status", "/v1/dns/status", "/v1/publicip/ip", "/v1/vpn/settings"]),
+ );
+ });
+
+ test("sends X-API-Key header when an apiKey secret is configured", async () => {
+ setupHappyPath();
+
+ await createIntegrationWithApiKey().getSummaryAsync();
+
+ for (const [, init] of mockFetch.mock.calls) {
+ const headers = (init?.headers ?? {}) as Record;
+ expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
+ expect(headers.Authorization).toBeUndefined();
+ }
+ });
+
+ test("sends Basic auth header when username/password are configured", async () => {
+ setupHappyPath();
+
+ await createIntegrationWithBasicAuth().getSummaryAsync();
+
+ const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
+ for (const [, init] of mockFetch.mock.calls) {
+ const headers = (init?.headers ?? {}) as Record;
+ expect(headers.Authorization).toBe(expected);
+ expect(headers["X-API-Key"]).toBeUndefined();
+ }
+ });
+
+ test("throws when any upstream endpoint returns a non-OK status", async () => {
+ mockFetch.mockImplementation((url) => {
+ const urlString = typeof url === "string" ? url : url.toString();
+ const { pathname } = new URL(urlString);
+
+ if (pathname === "/v1/publicip/ip") {
+ return Promise.resolve(jsonResponse({ error: "unauthorized" }, { status: 401 }));
+ }
+ return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
+ });
+
+ await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
+ });
+
+ test("throws when an upstream payload fails schema validation", async () => {
+ mockFetch.mockImplementation((url) => {
+ const urlString = typeof url === "string" ? url : url.toString();
+ const { pathname } = new URL(urlString);
+
+ if (pathname === "/v1/publicip/ip") {
+ return Promise.resolve(jsonResponse({ public_ip: 123 }));
+ }
+ return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
+ });
+
+ await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
+ });
+});
+
+describe("GluetunIntegration testing endpoint", () => {
+ type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
+
+ const invokeTesting = (integration: GluetunIntegration, input: IntegrationTestingInput) => {
+ const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
+ return testing(input);
+ };
+
+ const makeInput = (response: Response): IntegrationTestingInput =>
+ ({
+ fetchAsync: vi.fn().mockResolvedValue(response),
+ }) as unknown as IntegrationTestingInput;
+
+ test("returns success when /v1/vpn/status responds 200", async () => {
+ const input = makeInput(new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }));
+
+ const result = await invokeTesting(createIntegrationWithApiKey(), input);
+
+ expect(result.success).toBe(true);
+ });
+
+ test("returns a status-code failure when /v1/vpn/status responds non-OK", async () => {
+ const input = makeInput(new Response("unauthorized", { status: 401 }));
+
+ const result = await invokeTesting(createIntegrationWithApiKey(), input);
+
+ expect(result.success).toBe(false);
+ });
+
+ test("passes the api-key header through to fetchAsync", async () => {
+ const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
+ const input = makeInput(response);
+
+ await invokeTesting(createIntegrationWithApiKey(), input);
+
+ const fetchAsyncMock = vi.mocked(input.fetchAsync);
+ const init = fetchAsyncMock.mock.calls[0]?.[1];
+ const headers = (init?.headers ?? {}) as Record;
+ expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
+ });
+
+ test("passes the Basic auth header through to fetchAsync when username/password are configured", async () => {
+ const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
+ const input = makeInput(response);
+
+ await invokeTesting(createIntegrationWithBasicAuth(), input);
+
+ const fetchAsyncMock = vi.mocked(input.fetchAsync);
+ const init = fetchAsyncMock.mock.calls[0]?.[1];
+ const headers = (init?.headers ?? {}) as Record;
+ const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
+ expect(headers.Authorization).toBe(expected);
+ expect(headers["X-API-Key"]).toBeUndefined();
+ });
+});
+
+describe("gluetun schemas", () => {
+ test("vpn status schema accepts a string status", () => {
+ expect(gluetunVpnStatusSchema.parse({ status: "running" })).toEqual({ status: "running" });
+ });
+
+ test("vpn status schema rejects a missing status", () => {
+ expect(() => gluetunVpnStatusSchema.parse({})).toThrow();
+ });
+
+ test("dns status schema mirrors vpn status schema", () => {
+ expect(gluetunDnsStatusSchema.parse({ status: "stopped" })).toEqual({ status: "stopped" });
+ expect(() => gluetunDnsStatusSchema.parse({ status: 42 })).toThrow();
+ });
+
+ test("public ip schema parses the full payload", () => {
+ expect(gluetunPublicIpSchema.parse(PUBLIC_IP_PAYLOAD)).toEqual(PUBLIC_IP_PAYLOAD);
+ });
+
+ test("public ip schema rejects a missing required field", () => {
+ const { city: _city, ...partial } = PUBLIC_IP_PAYLOAD;
+ expect(() => gluetunPublicIpSchema.parse(partial)).toThrow();
+ });
+
+ test("vpn settings schema parses a wireguard payload", () => {
+ const parsed = gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD);
+ expect(parsed.type).toBe("wireguard");
+ expect(parsed.provider.name).toBe("mullvad");
+ });
+
+ test("vpn settings schema accepts nullable server-selection arrays", () => {
+ const payloadWithNulls = {
+ ...VPN_SETTINGS_PAYLOAD,
+ provider: {
+ ...VPN_SETTINGS_PAYLOAD.provider,
+ server_selection: {
+ ...VPN_SETTINGS_PAYLOAD.provider.server_selection,
+ countries: null,
+ cities: null,
+ },
+ },
+ };
+
+ expect(() => gluetunVpnSettingsSchema.parse(payloadWithNulls)).not.toThrow();
+ });
+
+ test("vpn settings schema rejects a non-string type", () => {
+ expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
+ });
+});
diff --git a/packages/integrations/src/gotify/gotify-integration.ts b/packages/integrations/src/gotify/gotify-integration.ts
index b7fd9361e..1d0f5eb8c 100644
--- a/packages/integrations/src/gotify/gotify-integration.ts
+++ b/packages/integrations/src/gotify/gotify-integration.ts
@@ -15,9 +15,9 @@ export class GotifyIntegration extends Integration implements INotificationsInte
}
private getHeaders() {
- const credentials = Buffer.from(
- `${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
- ).toString("base64");
+ const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString(
+ "base64",
+ );
return { Authorization: `Basic ${credentials}` };
}
@@ -31,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
const parsed = result.data;
- return parsed.messages.map((message): Notification => ({
- id: String(message.id),
- time: new Date(message.date),
- title: message.title,
- body: message.message,
- }));
+ return parsed.messages.map(
+ (message): Notification => ({
+ id: String(message.id),
+ time: new Date(message.date),
+ title: message.title,
+ body: message.message,
+ }),
+ );
}
}
diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts
index 9e13ff953..c01386256 100644
--- a/packages/integrations/src/immich/immich-integration.ts
+++ b/packages/integrations/src/immich/immich-integration.ts
@@ -1,4 +1,13 @@
-import { getAlbumInfo, getAllAlbums, getMyUser, getServerStatistics, init, searchUsers } from "@immich/sdk";
+import {
+ getAlbumInfo,
+ getAllAlbums,
+ getMyUser,
+ getServerStatistics,
+ init,
+ searchAssets,
+ searchUsers,
+} from "@immich/sdk";
+import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -23,7 +32,6 @@ export interface ImmichAlbum {
export interface ImmichAsset {
id: string;
- deviceAssetId: string;
originalPath: string;
thumbhash: string | null;
fileModifiedAt: string;
@@ -56,12 +64,16 @@ export class ImmichIntegration extends Integration {
public async getAlbumAsync(albumId: string): Promise {
this.initClient();
- const album = await getAlbumInfo({ id: albumId }, this.getRequestOptions());
+ const requestOptions = this.getRequestOptions();
+ const [album, albumAssets] = await Promise.all([
+ getAlbumInfo({ id: albumId }, requestOptions),
+ this.searchAlbumAssetsAsync(albumId),
+ ]);
const imageProxy = new ImageProxy();
return {
albumName: album.albumName,
assets: await Promise.all(
- album.assets.map(async (asset) => {
+ albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
this.url(`/api/assets/${asset.id}/original`).toString(),
{
@@ -71,7 +83,6 @@ export class ImmichIntegration extends Integration {
return {
id: asset.id,
type: asset.type,
- deviceAssetId: asset.deviceAssetId,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
@@ -104,6 +115,24 @@ export class ImmichIntegration extends Integration {
return { success: true };
}
+ private async searchAlbumAssetsAsync(albumId: string): Promise {
+ const requestOptions = this.getRequestOptions();
+ const assets: AssetResponseDto[] = [];
+ let nextPage: string | null = null;
+
+ do {
+ const metadataSearchDto: MetadataSearchDto = { albumIds: [albumId] };
+ if (nextPage !== null) {
+ metadataSearchDto.page = Number(nextPage);
+ }
+ const searchResult = await searchAssets({ metadataSearchDto }, requestOptions);
+ assets.push(...searchResult.assets.items);
+ nextPage = searchResult.assets.nextPage;
+ } while (nextPage !== null);
+
+ return assets;
+ }
+
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return {
// Undici and node types are not the same
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index 66cf4be51..8e20bf745 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -1,6 +1,6 @@
// General integrations
-export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
+export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
@@ -64,7 +64,6 @@ export type {
TdarrStatistics,
TdarrWorker,
} from "./interfaces/media-transcoding/media-transcoding-types";
-export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
export type { Notification } from "./interfaces/notifications/notification-types";
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
@@ -75,17 +74,17 @@ export type {
AnchorNotesListInput,
AnchorNoteSummary,
} from "./anchor/anchor-types";
-export type {
- ArchiveTeamWarriorBandwidth,
- ArchiveTeamWarriorItem,
- ArchiveTeamWarriorStatus,
-} from "./archive-team-warrior/archive-team-warrior-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types";
export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
+export type {
+ ArchiveTeamWarriorBandwidth,
+ ArchiveTeamWarriorItem,
+ ArchiveTeamWarriorStatus,
+} from "./archive-team-warrior/archive-team-warrior-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
diff --git a/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts b/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts
deleted file mode 100644
index e94f555fc..000000000
--- a/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { ReleaseProviderResponse, ReleaseResponse } from "./releases-providers-types";
-
-export const getLatestRelease = (
- releases: ReleaseProviderResponse[],
- versionRegex?: string,
-): ReleaseProviderResponse | null => {
- const validReleases = releases.filter((result) => {
- if (result.latestRelease) {
- return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true;
- }
- return true;
- });
-
- return validReleases.length === 0
- ? null
- : validReleases.reduce((latest, current) => (current.latestReleaseAt > latest.latestReleaseAt ? current : latest));
-};
-
-export interface ReleasesProviderIntegration {
- getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise;
-}
diff --git a/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts b/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts
deleted file mode 100644
index fb89b0fcb..000000000
--- a/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { TranslationObject } from "@homarr/translation";
-
-export interface ReleasesRepository extends Record {
- id: string;
- identifier: string;
- versionRegex?: string;
-}
-
-export interface DetailsProviderResponse {
- projectUrl?: string;
- projectDescription?: string;
- isFork?: boolean;
- isArchived?: boolean;
- createdAt?: Date;
- starsCount?: number;
- openIssues?: number;
- forksCount?: number;
-}
-
-export interface ReleaseProviderResponse {
- latestRelease: string;
- latestReleaseAt: Date;
- releaseUrl?: string;
- releaseDescription?: string;
- isPreRelease?: boolean;
-}
-
-type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"];
-
-export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse;
-
-export type ReleaseError = { code: ReleasesErrorKeys } | { code: "unexpected"; message: string };
-
-export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError };
diff --git a/packages/integrations/src/interfaces/timetable/timetable-integration.ts b/packages/integrations/src/interfaces/timetable/timetable-integration.ts
deleted file mode 100644
index 74e9d3c02..000000000
--- a/packages/integrations/src/interfaces/timetable/timetable-integration.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { Station, Timetable, TimetableOptions } from "./timetable-types";
-
-export interface TimetableIntegration {
- searchStationsAsync(query: string): Promise;
- getTimetableAsync(options: TimetableOptions): Promise;
-}
diff --git a/packages/integrations/src/interfaces/timetable/timetable-types.ts b/packages/integrations/src/interfaces/timetable/timetable-types.ts
deleted file mode 100644
index 72350b5a3..000000000
--- a/packages/integrations/src/interfaces/timetable/timetable-types.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import z from "zod";
-
-export interface Station {
- id: string;
- name: string;
-}
-
-export const timetableOptionsSchema = z.object({
- stationId: z.string(),
- limit: z.number().default(10),
-});
-
-export type TimetableOptions = z.infer;
-
-export interface TimetableEntry {
- timestamp: Date;
- delay: number;
- line: {
- name: string;
- color: string | null;
- } | null;
- location: string;
- platform: {
- name: string;
- hasChanged: boolean;
- } | null;
-}
-
-export interface Timetable {
- stationId: string;
- timestamp: Date;
- entries: TimetableEntry[];
-}
diff --git a/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts
new file mode 100644
index 000000000..39e500d30
--- /dev/null
+++ b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts
@@ -0,0 +1,5 @@
+import type { VpnSummary } from "./vpn-summary-types";
+
+export interface VpnSummaryIntegration {
+ getSummaryAsync(): Promise;
+}
diff --git a/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts
new file mode 100644
index 000000000..91e70454c
--- /dev/null
+++ b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts
@@ -0,0 +1,13 @@
+export interface VpnProviderDetails {
+ protocol: string;
+ provider: string;
+}
+
+export interface VpnSummary {
+ vpnStatus: string;
+ dnsStatus: string;
+ publicIp: string;
+ country: string;
+ city: string;
+ vpnProvider: VpnProviderDetails;
+}
diff --git a/packages/integrations/src/linuxserverio/linuxserverio-integration.ts b/packages/integrations/src/linuxserverio/linuxserverio-integration.ts
deleted file mode 100644
index 3178f2c4b..000000000
--- a/packages/integrations/src/linuxserverio/linuxserverio-integration.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-
-import type { IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import { TestConnectionError } from "../base/test-connection/test-connection-error";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
-import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types";
-import { releasesResponseSchema } from "./linuxserverio-schemas";
-
-const logger = createLogger({ module: "linuxServerIOIntegration" });
-
-export class LinuxServerIOIntegration extends Integration implements ReleasesProviderIntegration {
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const response = await input.fetchAsync(this.url("/health"));
-
- if (!response.ok) {
- return TestConnectionError.StatusResult(response);
- }
-
- return {
- success: true,
- };
- }
-
- private parseIdentifier(identifier: string) {
- const [owner, name] = identifier.split("/");
- if (!owner || !name) {
- logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", { identifier });
- return null;
- }
- return { owner, name };
- }
-
- public async getLatestMatchingReleaseAsync(identifier: string): Promise {
- const { name } = this.parseIdentifier(identifier) ?? {};
- if (!name) return { success: false, error: { code: "invalidIdentifier" } };
-
- const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images"));
- if (!releasesResponse.ok) {
- return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
- }
-
- const releasesResponseJson: unknown = await releasesResponse.json();
- const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
- if (!success) {
- return { success: false, error: { code: "unexpected", message: error.message } };
- }
-
- const release = data.data.repositories.linuxserver.find((repo) => repo.name === name);
- if (!release) {
- logger.warn("Repository not found on provider", {
- name,
- });
- return { success: false, error: { code: "noMatchingVersion" } };
- }
-
- return {
- success: true,
- data: {
- latestRelease: release.version,
- latestReleaseAt: release.version_timestamp,
- releaseDescription: release.changelog?.shift()?.desc,
- projectUrl: release.github_url,
- projectDescription: release.description,
- isArchived: release.deprecated,
- createdAt: release.initial_date ? new Date(release.initial_date) : undefined,
- starsCount: release.stars,
- },
- };
- }
-}
diff --git a/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts b/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts
deleted file mode 100644
index 3e3653697..000000000
--- a/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { z } from "zod/v4";
-
-export const releasesResponseSchema = z.object({
- data: z.object({
- repositories: z.object({
- linuxserver: z.array(
- z.object({
- name: z.string(),
- initial_date: z
- .string()
- .transform((value) => new Date(value))
- .optional(),
- github_url: z.string(),
- description: z.string(),
- version: z.string(),
- version_timestamp: z.string().transform((value) => new Date(value)),
- stars: z.number(),
- deprecated: z.boolean(),
- changelog: z
- .array(
- z.object({
- date: z.string().transform((value) => new Date(value)),
- desc: z.string(),
- }),
- )
- .optional(),
- }),
- ),
- }),
- }),
-});
diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
index 0bac3ec30..5b16b3ea8 100644
--- a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
+++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
@@ -69,36 +69,24 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
private getLinksForLidarrCalendarEvent = (event: z.infer) => {
const links: CalendarLink[] = [];
+ // titleSlug is only present when Lidarr returns it; guard so a missing value
+ // neither breaks the link nor drops the event.
+ if (event.artist.titleSlug) {
+ links.push({
+ href: this.externalUrl(`/artist/${event.artist.titleSlug}`).toString(),
+ name: "Lidarr",
+ logo: "/images/apps/lidarr.svg",
+ color: undefined,
+ isDark: true,
+ });
+ }
+
for (const link of event.artist.links) {
- switch (link.name) {
- case "vgmdb":
- links.push({
- href: link.url,
- name: "VgmDB",
- color: "#f5c518",
- isDark: false,
- logo: "/images/apps/vgmdb.svg",
- });
- break;
- case "imdb":
- links.push({
- href: link.url,
- name: "IMDb",
- color: "#f5c518",
- isDark: false,
- logo: "/images/apps/imdb.png",
- });
- break;
- case "last":
- links.push({
- href: link.url,
- name: "LastFM",
- color: "#cf222a",
- isDark: false,
- logo: "/images/apps/lastfm.svg",
- });
- break;
+ const externalLink = lidarrExternalCalendarLinks[link.name];
+ if (!externalLink) {
+ continue;
}
+ links.push({ ...externalLink, href: link.url });
}
return links;
@@ -109,7 +97,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
): z.infer["images"][number] | undefined => {
const flatImages = [...event.images];
- const sortedImages = flatImages.sort(
+ const sortedImages = flatImages.toSorted(
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
@@ -149,6 +137,16 @@ const lidarrCalendarEventSchema = z.object({
title: z.string(),
overview: z.string().optional(),
images: lidarrCalendarEventImageSchema,
- artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }),
+ artist: z.object({
+ links: z.array(z.object({ url: z.string().url(), name: z.string() })),
+ artistName: z.string(),
+ titleSlug: z.string().optional(),
+ }),
releaseDate: z.string().transform((value) => new Date(value)),
});
+
+const lidarrExternalCalendarLinks: Record> = {
+ vgmdb: { name: "VgmDB", color: "#f5c518", isDark: false, logo: "/images/apps/vgmdb.svg" },
+ imdb: { name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.png" },
+ last: { name: "LastFM", color: "#cf222a", isDark: false, logo: "/images/apps/lastfm.svg" },
+};
diff --git a/packages/integrations/src/npm/npm-integration.ts b/packages/integrations/src/npm/npm-integration.ts
deleted file mode 100644
index b0c8c050b..000000000
--- a/packages/integrations/src/npm/npm-integration.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-
-import type { IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import { TestConnectionError } from "../base/test-connection/test-connection-error";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
-import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
-import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types";
-import { releasesResponseSchema } from "./npm-schemas";
-
-export class NPMIntegration extends Integration implements ReleasesProviderIntegration {
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const response = await input.fetchAsync(this.url("/"));
-
- if (!response.ok) {
- return TestConnectionError.StatusResult(response);
- }
-
- return {
- success: true,
- };
- }
-
- public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise {
- if (!identifier) return { success: false, error: { code: "invalidIdentifier" } };
-
- const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url(`/${encodeURIComponent(identifier)}`));
- if (!releasesResponse.ok) {
- return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
- }
-
- const releasesResponseJson: unknown = await releasesResponse.json();
- const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
- if (!success) {
- return {
- success: false,
- error: {
- code: "unexpected",
- message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message,
- },
- };
- }
-
- const formattedReleases = data.time.map((tag) => ({
- ...tag,
- releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(data.name)}/v/${encodeURIComponent(tag.latestRelease)}`,
- releaseDescription: data.versions[tag.latestRelease]?.description ?? "",
- }));
-
- const latestRelease = getLatestRelease(formattedReleases, versionRegex);
- if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
-
- return { success: true, data: latestRelease };
- }
-}
diff --git a/packages/integrations/src/npm/npm-schemas.ts b/packages/integrations/src/npm/npm-schemas.ts
deleted file mode 100644
index 5d21734e2..000000000
--- a/packages/integrations/src/npm/npm-schemas.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { z } from "zod/v4";
-
-export const releasesResponseSchema = z.object({
- time: z
- .record(
- z.string(),
- z.string().transform((value) => new Date(value)),
- )
- .transform((version) =>
- Object.entries(version).map(([key, value]) => ({
- latestRelease: key,
- latestReleaseAt: value,
- })),
- ),
- versions: z.record(z.string(), z.object({ description: z.string() })),
- name: z.string(),
-});
diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts
index 9c2cb8a4f..a031343c0 100644
--- a/packages/integrations/src/plex/plex-integration.ts
+++ b/packages/integrations/src/plex/plex-integration.ts
@@ -127,11 +127,18 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const media =
data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => {
+ const title =
+ item.type === "episode"
+ ? (item.grandparentTitle ?? item.title)
+ : item.type === "season"
+ ? (item.parentTitle ?? item.title)
+ : item.title;
+
return {
id: item.Media?.at(0)?.id.toString() ?? item.key,
type: mapType(item.type),
- title: item.title,
- subtitle: item.tagline,
+ title,
+ subtitle: title === item.title ? item.tagline : item.title,
description: item.summary,
releaseDate: item.originallyAvailableAt
? new Date(item.originallyAvailableAt)
@@ -222,6 +229,8 @@ const recentlyAddedSchema = z.object({
studio: z.string().optional(),
type: z.string(), // For example "movie", "album"
title: z.string(),
+ parentTitle: z.string().optional(),
+ grandparentTitle: z.string().optional(),
summary: z.string().optional(),
duration: z.number().optional(),
addedAt: z.number(),
diff --git a/packages/integrations/src/plex/test/plex-integration.spec.ts b/packages/integrations/src/plex/test/plex-integration.spec.ts
new file mode 100644
index 000000000..64e046256
--- /dev/null
+++ b/packages/integrations/src/plex/test/plex-integration.spec.ts
@@ -0,0 +1,140 @@
+// @vitest-environment node
+import { Response } from "undici";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationSecret } from "../../base/types";
+import { PlexIntegration } from "../plex-integration";
+
+const imageProxyMocks = vi.hoisted(() => ({
+ createImageAsync: vi.fn((url: string) => Promise.resolve(`proxied:${url}`)),
+}));
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
+
+vi.mock("@homarr/image-proxy", () => ({
+ ImageProxy: class {
+ createImageAsync = imageProxyMocks.createImageAsync;
+ },
+}));
+
+const TEST_URL = "https://plex.example.com";
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+
+const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "plex-token" }];
+
+const createIntegration = () =>
+ new PlexIntegration({
+ id: "test-plex",
+ name: "Test Plex",
+ url: TEST_URL,
+ externalUrl: null,
+ decryptedSecrets: secrets,
+ });
+
+const createMetadataItem = (key: string, overrides: Record) => ({
+ key: `/library/metadata/${key}`,
+ type: "movie",
+ title: "Movie",
+ addedAt: 1_735_689_600,
+ Image: [
+ {
+ type: "coverPoster",
+ url: `/library/metadata/${key}/thumb`,
+ },
+ ],
+ ...overrides,
+});
+
+beforeEach(() => {
+ mockFetch.mockReset();
+ imageProxyMocks.createImageAsync.mockClear();
+});
+
+describe("PlexIntegration.getMediaReleasesAsync", () => {
+ test("uses the series title for TV seasons and episodes", async () => {
+ mockFetch.mockImplementation((url) => {
+ const urlString = String(url);
+
+ if (urlString.endsWith("/identity")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ MediaContainer: { machineIdentifier: "server-id" } }), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ if (urlString.endsWith("/library/recentlyAdded")) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ MediaContainer: {
+ Metadata: [
+ createMetadataItem("season", {
+ type: "season",
+ title: "Season 1",
+ parentTitle: "Breaking Bad",
+ }),
+ createMetadataItem("episode", {
+ type: "episode",
+ title: "Review",
+ parentTitle: "Season 1",
+ grandparentTitle: "The Bear",
+ }),
+ createMetadataItem("movie", {
+ type: "movie",
+ title: "Inception",
+ tagline: "Dreams feel real",
+ }),
+ createMetadataItem("show", {
+ type: "show",
+ title: "The Last of Us",
+ tagline: "A series tagline",
+ }),
+ createMetadataItem("missing-parent", {
+ type: "season",
+ title: "Season 3",
+ }),
+ ],
+ },
+ }),
+ { status: 200 },
+ ),
+ ) as unknown as ReturnType;
+ }
+
+ throw new Error(`Unexpected Plex request: ${urlString}`);
+ });
+
+ const releases = await createIntegration().getMediaReleasesAsync();
+
+ expect(releases).toMatchObject([
+ {
+ title: "Breaking Bad",
+ subtitle: "Season 1",
+ type: "tv",
+ },
+ {
+ title: "The Bear",
+ subtitle: "Review",
+ type: "tv",
+ },
+ {
+ title: "Inception",
+ subtitle: "Dreams feel real",
+ type: "movie",
+ },
+ {
+ title: "The Last of Us",
+ subtitle: "A series tagline",
+ type: "tv",
+ },
+ {
+ title: "Season 3",
+ subtitle: undefined,
+ type: "tv",
+ },
+ ]);
+ });
+});
\ No newline at end of file
diff --git a/packages/integrations/src/quay/quay-integration.ts b/packages/integrations/src/quay/quay-integration.ts
deleted file mode 100644
index 21cbf797e..000000000
--- a/packages/integrations/src/quay/quay-integration.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import type { RequestInit, Response } from "undici";
-
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-
-import type { IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import { TestConnectionError } from "../base/test-connection/test-connection-error";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
-import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
-import type {
- ReleaseProviderResponse,
- ReleaseResponse,
-} from "../interfaces/releases-providers/releases-providers-types";
-import { releasesResponseSchema } from "./quay-schemas";
-
-const logger = createLogger({ module: "quayIntegration" });
-
-export class QuayIntegration extends Integration implements ReleasesProviderIntegration {
- private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise {
- if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
-
- return await callback({
- Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
- });
- }
-
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const response = await this.withHeadersAsync(async (headers) => {
- return await input.fetchAsync(this.url("/api/v1/discovery"), {
- headers,
- });
- });
-
- if (!response.ok) {
- return TestConnectionError.StatusResult(response);
- }
-
- return {
- success: true,
- };
- }
-
- private parseIdentifier(identifier: string) {
- const [owner, name] = identifier.split("/");
- if (!owner || !name) {
- logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", {
- identifier,
- });
- return null;
- }
- return { owner, name };
- }
-
- public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise {
- const parsedIdentifier = this.parseIdentifier(identifier);
- if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
-
- const { owner, name } = parsedIdentifier;
-
- const releasesResponse = await this.withHeadersAsync(async (headers) => {
- return await fetchWithTrustedCertificatesAsync(
- this.url(
- `/api/v1/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?includeTags=true&includeStats=true`,
- ),
- {
- headers,
- },
- );
- });
- if (!releasesResponse.ok) {
- return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
- }
-
- const releasesResponseJson: unknown = await releasesResponse.json();
- const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
- if (!success) {
- return { success: false, error: { code: "unexpected", message: error.message } };
- }
-
- const releasesProviderResponse = Object.entries(data.tags).reduce((acc, [_, tag]) => {
- if (!tag.name || !tag.last_modified) return acc;
- acc.push({
- latestRelease: tag.name,
- latestReleaseAt: new Date(tag.last_modified),
- releaseUrl: `https://quay.io/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tag/${encodeURIComponent(tag.name)}`,
- });
- return acc;
- }, []);
-
- const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
- if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
-
- return { success: true, data: { projectDescription: data.description, ...latestRelease } };
- }
-}
diff --git a/packages/integrations/src/quay/quay-schemas.ts b/packages/integrations/src/quay/quay-schemas.ts
deleted file mode 100644
index e24fe2d83..000000000
--- a/packages/integrations/src/quay/quay-schemas.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { z } from "zod/v4";
-
-export const releasesResponseSchema = z.object({
- description: z.string().optional(),
- tags: z.record(
- z.string(),
- z.object({
- name: z.string(),
- last_modified: z.string(),
- }),
- ),
-});
diff --git a/packages/integrations/src/search-ch/search-ch-integration.ts b/packages/integrations/src/search-ch/search-ch-integration.ts
deleted file mode 100644
index cbaf8e80d..000000000
--- a/packages/integrations/src/search-ch/search-ch-integration.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import z from "zod";
-
-import { ResponseError } from "@homarr/common/server";
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-
-import type { IntegrationTestingInput } from "../base/integration";
-import { Integration } from "../base/integration";
-import { TestConnectionError } from "../base/test-connection/test-connection-error";
-import type { TestingResult } from "../base/test-connection/test-connection-service";
-import type { TimetableIntegration } from "../interfaces/timetable/timetable-integration";
-import type { Station, Timetable, TimetableOptions } from "../interfaces/timetable/timetable-types";
-
-const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
-
-export class SearchChIntegration extends Integration implements TimetableIntegration {
- protected async testingAsync(input: IntegrationTestingInput): Promise {
- const response = await input.fetchAsync(this.url("/timetable/api/completion.json", { term: "Zürich" }));
-
- if (!response.ok) {
- return TestConnectionError.StatusResult(response);
- }
-
- return {
- success: true,
- };
- }
-
- /**
- * Searches for stations matching the given query.
- * @param query The search query for station names.
- * @returns A list of stations matching the search query.
- * @docs https://search.ch/fahrplan/api/help
- */
- public async searchStationsAsync(query: string): Promise {
- const response = await fetchWithTrustedCertificatesAsync(
- this.url("/timetable/api/completion.json", {
- term: query,
- show_ids: 1,
- nofavorites: 1,
- }),
- );
-
- if (!response.ok) {
- throw new ResponseError(response);
- }
-
- const data = await searchSchema.parseAsync(await response.json());
-
- return (
- data
- // Remove completions like addresses, we only want stations.
- .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
- .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
- .filter((item) => item !== null)
- );
- }
-
- /**
- * Gets the departure timetable for a given station.
- * @param options Timetable options including station ID and limit.
- * @returns timetable for the given station.
- * @docs https://search.ch/fahrplan/api/help
- */
- public async getTimetableAsync(options: TimetableOptions): Promise {
- const now = new Date();
- // The following format is used [01.01.2000, 23:59:00]. The API allows usage of DD.MM.YYYY format and HH:mm format
- const [date, time] = now.toLocaleString("en-CH", { timeZone: "Europe/Zurich" }).split(", ") as [string, string];
-
- const response = await fetchWithTrustedCertificatesAsync(
- this.url("/timetable/api/stationboard.json", {
- stop: options.stationId,
- limit: options.limit,
- show_delays: 1,
- show_tracks: 1,
- date,
- time: time.substring(0, 5), // Only keep the HH:mm part of the time, as that's what the API expects
- }),
- );
-
- if (!response.ok) {
- throw new ResponseError(response);
- }
-
- const data = await timetableSchema.parseAsync(await response.json());
-
- return {
- stationId: options.stationId,
- timestamp: now,
- entries: data.connections.map((connection) => {
- // Color is in format A35~F10~ for foreground and background color. Some only have ~~ and therefore no color
- const color = connection.color.split("~")[0];
-
- return {
- timestamp: connection.time,
- line: connection.line
- ? {
- name: connection.line,
- color: color && color.length >= 1 ? `#${color}` : null,
- }
- : null,
- location: connection.terminal.name,
- delay: connection.dep_delay,
- platform: connection.track
- ? {
- // If the track name contains "!", it indicates a change in the track
- // so we remove the "!" from the name and set hasChanged to true
- name: connection.track.replace("!", ""),
- hasChanged: connection.track.includes("!"),
- }
- : null,
- };
- }),
- };
- }
-}
-
-const timetableSchema = z.object({
- connections: z.array(
- z.object({
- time: z.string().transform((str) => new Date(str)),
- line: z.string().optional(),
- color: z.string(),
- terminal: z.object({
- name: z.string(),
- }),
- track: z.string().optional(),
- dep_delay: z
- .string()
- .optional()
- .transform((str) => (str ? parseInt(str) : 0)),
- }),
- ),
-});
-
-const searchSchema = z.array(
- z.object({
- id: z.string().optional(),
- label: z.string(),
- iconclass: z.string(), // This is in format sl-icon-type-{type}
- }),
-);
diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts
index 1ee395dfa..273b5b2d2 100644
--- a/packages/integrations/src/types.ts
+++ b/packages/integrations/src/types.ts
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/ups-summary/ups-summary-types";
+export * from "./interfaces/vpn-summary/vpn-summary-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
@@ -14,7 +15,6 @@ export * from "./opnsense/opnsense-types";
export * from "./interfaces/media-releases";
export * from "./coolify/coolify-types";
export * from "./anchor/anchor-types";
-export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
export * from "./uptime-kuma/uptime-kuma-types";
@@ -22,4 +22,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
export * from "./navidrome/navidrome-types";
export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
+export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
diff --git a/packages/integrations/test/glances-unit.spec.ts b/packages/integrations/test/glances-unit.spec.ts
index 121ab594a..07f908e9e 100644
--- a/packages/integrations/test/glances-unit.spec.ts
+++ b/packages/integrations/test/glances-unit.spec.ts
@@ -1,11 +1,11 @@
// @vitest-environment node
-import { describe, expect, test, vi } from "vitest";
+import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
-import { GlancesIntegration } from "../src/glances/glances-integration";
+import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration";
// ─── module mocks ────────────────────────────────────────────────────────────
@@ -64,6 +64,19 @@ function makeResponse(body: unknown) {
} as never;
}
+function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown) {
+ mockFetch.mockImplementation(((url: string | URL) => {
+ const urlString = url.toString();
+ if (urlString.endsWith("/api/4/all")) {
+ return Promise.resolve(makeResponse(statsBody));
+ }
+ if (urlString.endsWith("/api/4/sensors")) {
+ return Promise.resolve(makeResponse(sensorsBody));
+ }
+ return Promise.reject(new Error(`Unexpected fetch URL: ${urlString}`));
+ }) as typeof fetchWithTrustedCertificatesAsync);
+}
+
const integrationInput = {
id: "test-glances",
name: "Glances",
@@ -75,8 +88,12 @@ const integrationInput = {
// ─── tests ───────────────────────────────────────────────────────────────────
describe("GlancesIntegration schema", () => {
+ beforeEach(() => {
+ mockFetch.mockReset();
+ });
+
test("parses stats when quicklook is present", async () => {
- mockFetch.mockResolvedValueOnce(makeResponse({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }));
+ mockGlancesFetch({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }, []);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -86,7 +103,7 @@ describe("GlancesIntegration schema", () => {
});
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
- mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
+ mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -95,9 +112,41 @@ describe("GlancesIntegration schema", () => {
});
test("does not throw a Zod parse error when quicklook is missing", async () => {
- mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
+ mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput);
await expect(integration.getSystemInfoAsync()).resolves.not.toThrow();
});
+
+ test("reads CPU temperature from sensors", async () => {
+ mockGlancesFetch(baseStats, [
+ { label: "CPU", unit: "C", value: 58, type: "temperature_core" },
+ { label: "Core 0", unit: "C", value: 54, type: "temperature_core" },
+ ]);
+
+ const integration = new GlancesIntegration(integrationInput);
+ const result = await integration.getSystemInfoAsync();
+
+ expect(result.cpuTemp).toBe(58);
+ });
+});
+
+describe("parseGlancesCpuTempFromSensors", () => {
+ test("prefers CPU label over core sensors", () => {
+ const result = parseGlancesCpuTempFromSensors([
+ { label: "Core 0", value: 54, type: "temperature_core" },
+ { label: "CPU", value: 58, type: "temperature_core" },
+ ]);
+
+ expect(result).toBe(58);
+ });
+
+ test("falls back to Package id 0", () => {
+ const result = parseGlancesCpuTempFromSensors([
+ { label: "Core 0", value: 54, type: "temperature_core" },
+ { label: "Package id 0", value: 62, type: "temperature_core" },
+ ]);
+
+ expect(result).toBe(62);
+ });
});
diff --git a/packages/integrations/test/lidarr-unit.spec.ts b/packages/integrations/test/lidarr-unit.spec.ts
new file mode 100644
index 000000000..b788565a6
--- /dev/null
+++ b/packages/integrations/test/lidarr-unit.spec.ts
@@ -0,0 +1,76 @@
+// @vitest-environment node
+
+import { describe, expect, test, vi } from "vitest";
+
+import { createDb } from "@homarr/db/test";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import { LidarrIntegration } from "../src/media-organizer/lidarr/lidarr-integration";
+
+vi.mock("@homarr/db", async (importActual) => {
+ const actual = await importActual();
+ return { ...actual, db: createDb() };
+});
+
+vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
+ };
+});
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
+
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+
+const integrationInput = {
+ id: "test-lidarr",
+ name: "Lidarr",
+ url: "http://localhost:8686",
+ decryptedSecrets: [{ kind: "apiKey" as const, value: "test-key" }],
+ externalUrl: null,
+};
+
+describe("LidarrIntegration calendar", () => {
+ test("includes Lidarr artist link in calendar events", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve([
+ {
+ title: "Test Album",
+ overview: "Overview",
+ images: [],
+ artist: {
+ artistName: "Test Artist",
+ titleSlug: "test-artist",
+ links: [{ name: "last", url: "https://last.fm/music/Test+Artist" }],
+ },
+ releaseDate: "2026-01-01T00:00:00Z",
+ },
+ ]),
+ } as never);
+
+ const integration = new LidarrIntegration(integrationInput);
+ const events = await integration.getCalendarEventsAsync(new Date("2026-01-01"), new Date("2026-01-31"));
+
+ expect(events[0]?.links[0]).toMatchObject({
+ name: "Lidarr",
+ href: "http://localhost:8686/artist/test-artist",
+ logo: "/images/apps/lidarr.svg",
+ });
+ // The existing external artist link must survive the Lidarr-link prepend.
+ expect(events[0]?.links).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: "LastFM",
+ href: "https://last.fm/music/Test+Artist",
+ logo: "/images/apps/lastfm.svg",
+ }),
+ ]),
+ );
+ });
+});
diff --git a/packages/integrations/test/technitium.spec.ts b/packages/integrations/test/technitium.spec.ts
index 99c9d2610..77189b556 100644
--- a/packages/integrations/test/technitium.spec.ts
+++ b/packages/integrations/test/technitium.spec.ts
@@ -2,15 +2,7 @@
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, Wait } from "testcontainers";
-import {
- beforeAll,
- beforeEach,
- afterAll,
- describe,
- expect,
- test,
- vi,
-} from "vitest";
+import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
- const actual =
- await importActual<
- typeof import("@homarr/core/infrastructure/certificates")
- >();
+ const actual = await importActual();
return {
...actual,
- getTrustedCertificateHostnamesAsync: vi
- .fn()
- .mockImplementation(() => Promise.resolve([])),
+ getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
};
});
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
const makeInput = (
baseUrl: string,
- auth:
- | { kind: "credentials"; password?: string }
- | { kind: "apiKey"; value: string },
+ auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
): IntegrationInput => ({
id: INTEGRATION_ID,
name: "Technitium DNS",
@@ -109,10 +94,7 @@ const makeInput = (
// Fetch an API token appropriate for the server version.
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
-async function fetchApiToken(
- baseUrl: string,
- apiVersion: TechnitiumVersion,
-): Promise {
+async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise {
if (apiVersion === "v15") {
// v15 exposes a dedicated endpoint for non-expiring API tokens.
const res = await fetch(
@@ -122,9 +104,7 @@ async function fetchApiToken(
}
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
// token that can be used as a long-lived API key via the ?token= query parameter.
- const res = await fetch(
- `${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
- );
+ const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
return ((await res.json()) as { token: string }).token;
}
@@ -144,16 +124,12 @@ const pool: Record = {};
beforeAll(async () => {
await Promise.all(
VERSIONS.map(async ({ tag, apiVersion }) => {
- const container = await new GenericContainer(
- `technitium/dns-server:${tag}`,
- )
+ const container = await new GenericContainer(`technitium/dns-server:${tag}`)
.withExposedPorts(5380)
// Explicitly set the admin password so all versions use the same credentials.
// Without this, older versions may generate a random password on first run.
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
- .withWaitStrategy(
- Wait.forLogMessage("Technitium DNS Server was started successfully."),
- )
+ .withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
.start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
const apiToken = await fetchApiToken(baseUrl, apiVersion);
@@ -163,238 +139,209 @@ beforeAll(async () => {
}, 120_000);
afterAll(async () => {
- await Promise.all(
- Object.values(pool).map(({ container }) => container.stop()),
- );
+ await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
});
// ─── version-parameterised suites ────────────────────────────────────────────
-describe.each(VERSIONS)(
- "Technitium DNS $tag ($apiVersion)",
- ({ tag, apiVersion }) => {
- let baseUrl: string;
- let apiToken: string;
+describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
+ let baseUrl: string;
+ let apiToken: string;
- beforeAll(() => {
- const entry = pool[tag];
- if (!entry)
- throw new Error(`Container pool entry for ${tag} was not initialized`);
- ({ baseUrl, apiToken } = entry);
- });
+ beforeAll(() => {
+ const entry = pool[tag];
+ if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
+ ({ baseUrl, apiToken } = entry);
+ });
- beforeEach(() => {
- sessionState[INTEGRATION_ID] = null;
- });
+ beforeEach(() => {
+ sessionState[INTEGRATION_ID] = null;
+ });
- // ── factory ────────────────────────────────────────────────────────────────
+ // ── factory ────────────────────────────────────────────────────────────────
- describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
- test("factory creates a working integration (auto-detects version)", async () => {
- const integration = await createTechnitiumDnsIntegrationAsync(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
+ test("factory creates a working integration (auto-detects version)", async () => {
+ const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
- }, 20_000);
- });
+ expect(result.status).toBe("enabled");
+ expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
+ }, 20_000);
+ });
- // ── getSummaryAsync ────────────────────────────────────────────────────────
+ // ── getSummaryAsync ────────────────────────────────────────────────────────
- describe("getSummaryAsync", () => {
- test("returns valid summary with credentials", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ describe("getSummaryAsync", () => {
+ test("returns valid summary with credentials", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
- expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
- expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
- expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
- expect(result.status).toBe("enabled");
- }, 20_000);
+ expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
+ expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
+ expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
+ expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
+ expect(result.status).toBe("enabled");
+ }, 20_000);
- test("returns valid summary with API key", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
- apiVersion,
- );
+ test("returns valid summary with API key", async () => {
+ const integration = new TechnitiumDnsIntegration(
+ makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
+ apiVersion,
+ );
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
- }, 20_000);
+ expect(result.status).toBe("enabled");
+ expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
+ }, 20_000);
- test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(typeof result.domainsBeingBlocked).toBe("number");
- expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
- }, 20_000);
- });
+ expect(typeof result.domainsBeingBlocked).toBe("number");
+ expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
+ }, 20_000);
+ });
- // ── session caching ────────────────────────────────────────────────────────
+ // ── session caching ────────────────────────────────────────────────────────
- describe("session caching", () => {
- test("login is performed once and token is cached for subsequent calls", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ describe("session caching", () => {
+ test("login is performed once and token is cached for subsequent calls", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.getSummaryAsync();
- const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
- expect(tokenAfterFirst).toBeDefined();
+ await integration.getSummaryAsync();
+ const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
+ expect(tokenAfterFirst).toBeDefined();
- await integration.getSummaryAsync();
- const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
+ await integration.getSummaryAsync();
+ const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
- expect(tokenAfterSecond).toBe(tokenAfterFirst);
- }, 20_000);
+ expect(tokenAfterSecond).toBe(tokenAfterFirst);
+ }, 20_000);
- test("recovers from an expired/invalid stored token by re-logging in", async () => {
- // Plant a bad token with the correct version so it hits the right API path before failing
- sessionState[INTEGRATION_ID] = {
- token: "deliberately-invalid-token",
- version: apiVersion,
- };
+ test("recovers from an expired/invalid stored token by re-logging in", async () => {
+ // Plant a bad token with the correct version so it hits the right API path before failing
+ sessionState[INTEGRATION_ID] = {
+ token: "deliberately-invalid-token",
+ version: apiVersion,
+ };
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- apiVersion,
- );
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- expect(sessionState[INTEGRATION_ID]).not.toBeNull();
- expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
- "deliberately-invalid-token",
- );
- }, 20_000);
- });
+ expect(result.status).toBe("enabled");
+ expect(sessionState[INTEGRATION_ID]).not.toBeNull();
+ expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
+ }, 20_000);
+ });
- // ── blocking state changes ─────────────────────────────────────────────────
+ // ── blocking state changes ─────────────────────────────────────────────────
- describe("blocking state changes", () => {
- test("disableAsync permanently disables blocking", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ describe("blocking state changes", () => {
+ test("disableAsync permanently disables blocking", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync();
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("disabled");
+ expect(result.status).toBe("disabled");
- await integration.enableAsync(); // restore
- }, 20_000);
+ await integration.enableAsync(); // restore
+ }, 20_000);
- test("enableAsync re-enables blocking after permanent disable", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("enableAsync re-enables blocking after permanent disable", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync();
- await integration.enableAsync();
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync();
+ await integration.enableAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- }, 20_000);
+ expect(result.status).toBe("enabled");
+ }, 20_000);
- test("disableAsync with duration temporarily disables blocking", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("disableAsync with duration temporarily disables blocking", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync(120); // 120s → 2 min
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync(120); // 120s → 2 min
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("disabled");
+ expect(result.status).toBe("disabled");
- await integration.enableAsync(); // restore
- }, 20_000);
+ await integration.enableAsync(); // restore
+ }, 20_000);
- test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync(1);
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync(1);
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("disabled");
+ expect(result.status).toBe("disabled");
- await integration.enableAsync(); // restore
- }, 20_000);
+ await integration.enableAsync(); // restore
+ }, 20_000);
- test("enableAsync cancels an active timed disable immediately", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("enableAsync cancels an active timed disable immediately", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync(600); // 10 minutes
- await integration.enableAsync();
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync(600); // 10 minutes
+ await integration.enableAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- }, 20_000);
- });
+ expect(result.status).toBe("enabled");
+ }, 20_000);
+ });
- // ── testConnectionAsync ────────────────────────────────────────────────────
+ // ── testConnectionAsync ────────────────────────────────────────────────────
- describe("testConnectionAsync", () => {
- test("succeeds with valid credentials", async () => {
- const result = await new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- ).testConnectionAsync();
- expect(result.success).toBe(true);
- }, 20_000);
+ describe("testConnectionAsync", () => {
+ test("succeeds with valid credentials", async () => {
+ const result = await new TechnitiumDnsIntegration(
+ makeInput(baseUrl, { kind: "credentials" }),
+ ).testConnectionAsync();
+ expect(result.success).toBe(true);
+ }, 20_000);
- test("succeeds with valid API key", async () => {
- const result = await new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
- apiVersion,
- ).testConnectionAsync();
- expect(result.success).toBe(true);
- }, 20_000);
+ test("succeeds with valid API key", async () => {
+ const result = await new TechnitiumDnsIntegration(
+ makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
+ apiVersion,
+ ).testConnectionAsync();
+ expect(result.success).toBe(true);
+ }, 20_000);
- test("fails with wrong password — returns authorization error", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, {
- kind: "credentials",
- password: "wrong-password",
- }),
- );
+ test("fails with wrong password — returns authorization error", async () => {
+ const integration = new TechnitiumDnsIntegration(
+ makeInput(baseUrl, {
+ kind: "credentials",
+ password: "wrong-password",
+ }),
+ );
- const result = await integration.testConnectionAsync();
+ const result = await integration.testConnectionAsync();
- expect(result.success).toBe(false);
- if (result.success) return;
- expect(result.error).toBeInstanceOf(TestConnectionError);
- expect(result.error.type).toBe("authorization");
- }, 20_000);
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error).toBeInstanceOf(TestConnectionError);
+ expect(result.error.type).toBe("authorization");
+ }, 20_000);
- test("fails with invalid API key — returns authorization error", async () => {
- const result = await new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
- apiVersion,
- ).testConnectionAsync();
+ test("fails with invalid API key — returns authorization error", async () => {
+ const result = await new TechnitiumDnsIntegration(
+ makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
+ apiVersion,
+ ).testConnectionAsync();
- expect(result.success).toBe(false);
- if (result.success) return;
- expect(result.error).toBeInstanceOf(TestConnectionError);
- expect(result.error.type).toBe("authorization");
- }, 20_000);
- });
- },
-);
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error).toBeInstanceOf(TestConnectionError);
+ expect(result.error.type).toBe("authorization");
+ }, 20_000);
+ });
+});
diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts
index 6c99c11b6..02258a2e0 100644
--- a/packages/redis/src/index.ts
+++ b/packages/redis/src/index.ts
@@ -1,42 +1,22 @@
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
-import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
+import { createListChannel, createSubPubChannel } from "./lib/channel";
export {
- createCacheChannel,
- createItemAndIntegrationChannel,
- createItemChannel,
- createIntegrationOptionsChannel,
- createWidgetOptionsChannel,
- createChannelWithLatestAndEvents,
- createChannelEventHistory,
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
- createQueryCacheChannel,
- getAllQueryCacheAsync,
+ setQueryCacheAsync,
+ getQueryCacheAsync,
+ removeQueryCacheAsync,
invalidateIntegrationCacheAsync,
} from "./lib/channel";
-export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
-
-export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const pingChannel = createSubPubChannel<
{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }
>("ping");
export const pingUrlChannel = createListChannel("ping-url");
-export const homeAssistantEntityState = createSubPubChannel<{
- entityId: string;
- state: string;
-}>("home-assistant/entity-state");
-
-export const queueChannel = createQueueChannel<{
- name: string;
- executionDate: Date;
- data: unknown;
-}>("common-queue");
-
export interface LoggerMessage {
message: string;
level: LogLevel;
diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts
index d04d1499a..1b1e582c1 100644
--- a/packages/redis/src/lib/channel.ts
+++ b/packages/redis/src/lib/channel.ts
@@ -1,8 +1,7 @@
import superjson from "superjson";
-import { createId, hashObjectBase64 } from "@homarr/common";
+import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
-import type { WidgetKind } from "@homarr/definitions";
import { ChannelSubscriptionTracker } from "./channel-subscription-tracker";
import { createRedisConnection } from "./connection";
@@ -129,218 +128,38 @@ export const createGetSetChannel = (name: string) => {
};
};
-interface QueryCacheChannelOptions {
- userId: string;
- boardId: string;
- key: string;
- ttlMs: number;
- maxValueBytes: number;
-}
+const queryCacheKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
-const queryCacheHashKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
-
-export const createQueryCacheChannel = ({ userId, boardId, key, ttlMs, maxValueBytes }: QueryCacheChannelOptions) => {
- const hkey = queryCacheHashKey(userId, boardId);
-
- return {
- name: `${hkey}:${key}`,
- getAsync: async () => {
- return await getSetClient.hget(hkey, key);
- },
- setAsync: async (value: string) => {
- if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
- logger.warn("Query cache value exceeded maximum size", {
- channel: hkey,
- valueBytes: Buffer.byteLength(value, "utf8"),
- maxValueBytes,
- });
- return false;
- }
-
- await getSetClient.hset(hkey, key, value);
- await getSetClient.pexpire(hkey, ttlMs);
- return true;
- },
- removeAsync: async () => {
- await getSetClient.hdel(hkey, key);
- },
- };
-};
-
-export const getAllQueryCacheAsync = async (userId: string, boardId: string): Promise> => {
- const hkey = queryCacheHashKey(userId, boardId);
- return await getSetClient.hgetall(hkey);
-};
-
-/**
- * Creates a new cache channel.
- * @param name name of the channel
- * @param cacheDurationMs duration in milliseconds to cache
- * @returns cache channel object
- */
-export const createCacheChannel = (name: string, cacheDurationMs: number = 5 * 60 * 1000) => {
- const cacheChannelName = `cache:${name}`;
-
- return {
- /**
- * Get the data from the cache channel.
- * @returns data or null if not found or expired
- */
- getAsync: async () => {
- const data = await getSetClient.get(cacheChannelName);
- if (!data) return null;
-
- const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data);
- const now = new Date();
- const diff = now.getTime() - parsedData.timestamp.getTime();
- if (diff > cacheDurationMs) return null;
-
- return parsedData;
- },
- /**
- * Consume the data from the cache channel, if not present or expired, it will call the callback to get new data.
- * @param callback callback function to get new data if not present or expired
- * @returns data or new data if not present or expired
- */
- consumeAsync: async (callback: () => Promise) => {
- const data = await getSetClient.get(cacheChannelName);
-
- const getNewDataAsync = async () => {
- logger.debug(`Cache miss for channel '${cacheChannelName}'`);
- const newData = await callback();
- const result = { data: newData, timestamp: new Date() };
- await getSetClient.set(cacheChannelName, superjson.stringify(result));
- logger.debug(`Cache updated for channel '${cacheChannelName}'`);
- return result;
- };
-
- if (!data) {
- return await getNewDataAsync();
- }
-
- const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data);
- const now = new Date();
- const diff = now.getTime() - parsedData.timestamp.getTime();
-
- if (diff > cacheDurationMs) {
- return await getNewDataAsync();
- }
-
- logger.debug(`Cache hit for channel '${cacheChannelName}'`);
-
- return parsedData;
- },
- /**
- * Invalidate the cache channels data.
- */
- invalidateAsync: async () => {
- await getSetClient.del(cacheChannelName);
- },
- /**
- * Set the data in the cache channel.
- * @param data data to be stored in the cache channel
- */
- setAsync: async (data: TData) => {
- await getSetClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() }));
- },
- };
-};
-
-export const createItemAndIntegrationChannel = (kind: WidgetKind, integrationId: string) => {
- const channelName = `item:${kind}:integration:${integrationId}`;
- return createChannelWithLatestAndEvents(channelName);
-};
-
-export const createIntegrationOptionsChannel = (
- integrationId: string,
- queryKey: string,
- options: Record,
+export const setQueryCacheAsync = async (
+ userId: string,
+ boardId: string,
+ value: string,
+ ttlMs: number,
+ maxValueBytes: number,
) => {
- const optionsKey = hashObjectBase64(options);
- const channelName = `integration:${integrationId}:${queryKey}:options:${optionsKey}`;
- return createChannelWithLatestAndEvents(channelName);
+ if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
+ logger.warn("Query cache value exceeded maximum size", {
+ key: queryCacheKey(userId, boardId),
+ valueBytes: Buffer.byteLength(value, "utf8"),
+ maxValueBytes,
+ });
+ return false;
+ }
+ await getSetClient.set(queryCacheKey(userId, boardId), value, "PX", ttlMs);
+ return true;
+};
+
+export const getQueryCacheAsync = async (userId: string, boardId: string) =>
+ await getSetClient.get(queryCacheKey(userId, boardId));
+
+export const removeQueryCacheAsync = async (userId: string, boardId: string) => {
+ await getSetClient.del(queryCacheKey(userId, boardId));
};
-/**
- * Invalidates all cached data for a given integration by deleting every Redis key
- * that starts with `integration:${integrationId}:` as well as the integration's
- * session store entry. This ensures that stale data from a misconfigured or
- * updated integration is never served from cache.
- */
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => {
const client = getSetClient as typeof getSetClient | null;
- if (!client) {
- return;
- }
-
- const patterns = [`integration:${integrationId}:*`, `session-store:${integrationId}`];
- let deletedCount = 0;
- for (const pattern of patterns) {
- let cursor = "0";
- do {
- const [nextCursor, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
- cursor = nextCursor;
- if (keys.length > 0) {
- await client.del(...keys);
- deletedCount += keys.length;
- }
- } while (cursor !== "0");
- }
- if (deletedCount > 0) {
- logger.info("Invalidated integration cache", { integrationId, deletedKeys: deletedCount });
- }
-};
-
-export const createWidgetOptionsChannel = (
- widgetKind: WidgetKind,
- queryKey: string,
- options: Record,
-) => {
- const optionsKey = hashObjectBase64(options);
- const channelName = `widget:${widgetKind}:${queryKey}:options:${optionsKey}`;
- return createChannelWithLatestAndEvents(channelName);
-};
-
-export const createItemChannel = (itemId: string) => {
- return createChannelWithLatestAndEvents(`item:${itemId}`);
-};
-
-export const createChannelEventHistory = (channelName: string, maxElements = 32) => {
- return {
- subscribe: (callback: (data: TData) => void) => {
- return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
- callback(superjson.parse(message));
- });
- },
- pushAsync: async (data: TData, options = { publish: false }) => {
- if (options.publish) await publisher.publish(channelName, superjson.stringify(data));
- await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
- await getSetClient.ltrim(channelName, 0, maxElements);
- },
- clearAsync: async () => {
- await getSetClient.del(channelName);
- },
- /**
- * Returns a slice of the available data in the channel.
- * If any of the indexes are out of range (or -range), returned data will be clamped.
- * @param startIndex Start index of the slice, negative values are counted from the end, defaults at beginning of range.
- * @param endIndex End index of the slice, negative values are counted from the end, defaults at end of range.
- */
- getSliceAsync: async (startIndex = 0, endIndex = -1) => {
- const range = await getSetClient.lrange(channelName, startIndex, endIndex);
- return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
- },
- getSliceUntilTimeAsync: async (time: Date) => {
- const itemsInCollection = await getSetClient.lrange(channelName, 0, -1);
- return itemsInCollection
- .map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item))
- .filter((item) => item.timestamp < time);
- },
- getLengthAsync: async () => {
- return await getSetClient.llen(channelName);
- },
- name: channelName,
- };
+ if (!client) return;
+ await client.del(`session-store:${integrationId}`);
};
/**
@@ -407,40 +226,6 @@ export const createChannelEventHistoryOld = (channelName: string, maxElem
};
};
-export const createChannelWithLatestAndEvents = (channelName: string) => {
- return {
- subscribe: (callback: (data: TData) => void) => {
- return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
- callback(superjson.parse(message));
- });
- },
- publishAndUpdateLastStateAsync: async (data: TData) => {
- // Set first, then publish. If publish fails the storage is still consistent
- // and subscribers will catch up on the next event.
- const payload = superjson.stringify({ data, timestamp: new Date() });
- await getSetClient.set(channelName, payload);
- try {
- await publisher.publish(channelName, superjson.stringify(data));
- } catch (error) {
- logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
- channel: channelName,
- error: error instanceof Error ? error.message : String(error),
- });
- }
- },
- setAsync: async (data: TData) => {
- await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
- },
- getAsync: async () => {
- const data = await getSetClient.get(channelName);
- if (!data) return null;
-
- return superjson.parse<{ data: TData; timestamp: Date }>(data);
- },
- name: channelName,
- };
-};
-
const queueClient = createRedisConnection();
type WithId = TItem & { _id: string };
diff --git a/packages/redis/src/lib/channels/history-channel.ts b/packages/redis/src/lib/channels/history-channel.ts
deleted file mode 100644
index 541830464..000000000
--- a/packages/redis/src/lib/channels/history-channel.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { createChannelEventHistory } from "../channel";
-
-export const createIntegrationHistoryChannel = (integrationId: string, queryKey: string, maxElements = 32) => {
- const channelName = `integration:${integrationId}:history:${queryKey}`;
- return createChannelEventHistory(channelName, maxElements);
-};
diff --git a/packages/request-handler/src/anchor-notes.ts b/packages/request-handler/src/anchor-notes.ts
index bd6f19e44..75677b8ba 100644
--- a/packages/request-handler/src/anchor-notes.ts
+++ b/packages/request-handler/src/anchor-notes.ts
@@ -1,30 +1,22 @@
-import dayjs from "dayjs";
-
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const anchorNotesListRequestHandler = createCachedIntegrationRequestHandler<
+export const anchorNotesListRequestHandler = createIntegrationRequestHandler<
AnchorNoteSummary[],
"anchor",
AnchorNotesListInput
>({
- queryKey: "anchorNotesList",
- cacheDuration: dayjs.duration(15, "seconds"),
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.listNotesAsync(input);
},
});
-export const anchorNoteRequestHandler = createCachedIntegrationRequestHandler(
- {
- queryKey: "anchorNote",
- cacheDuration: dayjs.duration(15, "seconds"),
- async requestAsync(integration, input) {
- const instance = await createIntegrationAsync(integration);
- return instance.getNoteAsync(input.noteId);
- },
+export const anchorNoteRequestHandler = createIntegrationRequestHandler({
+ async requestAsync(integration, input) {
+ const instance = await createIntegrationAsync(integration);
+ return instance.getNoteAsync(input.noteId);
},
-);
+});
diff --git a/packages/request-handler/src/archive-team-warrior.ts b/packages/request-handler/src/archive-team-warrior.ts
index 4038ec9dc..e02391f19 100644
--- a/packages/request-handler/src/archive-team-warrior.ts
+++ b/packages/request-handler/src/archive-team-warrior.ts
@@ -1,12 +1,10 @@
-import dayjs from "dayjs";
-
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler<
+export const archiveTeamWarriorRequestHandler = createIntegrationRequestHandler<
ArchiveTeamWarriorStatus,
IntegrationKindByCategory<"archiving">,
Record
@@ -15,6 +13,4 @@ export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHa
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatusAsync();
},
- cacheDuration: dayjs.duration(5, "minute"),
- queryKey: "archiveTeamWarriorStatus",
});
diff --git a/packages/request-handler/src/audiobookshelf.ts b/packages/request-handler/src/audiobookshelf.ts
index bb4b2d83c..12562342c 100644
--- a/packages/request-handler/src/audiobookshelf.ts
+++ b/packages/request-handler/src/audiobookshelf.ts
@@ -1,11 +1,9 @@
-import dayjs from "dayjs";
-
import { createIntegrationAsync } from "@homarr/integrations";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandler<
+export const audiobookshelfRequestHandler = createIntegrationRequestHandler<
AudiobookshelfDashboardData,
"audiobookshelf",
Record
@@ -14,6 +12,4 @@ export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandle
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
- cacheDuration: dayjs.duration(10, "minutes"),
- queryKey: "audiobookshelfDashboard",
});
diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts
index 6dbe20ae0..4a60a3a3a 100644
--- a/packages/request-handler/src/beszel.ts
+++ b/packages/request-handler/src/beszel.ts
@@ -1,5 +1,3 @@
-import dayjs from "dayjs";
-
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
@@ -12,7 +10,7 @@ import type {
BeszelSystemStatsRecord,
} from "@homarr/integrations/types";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const logger = createLogger({ module: "beszelRequestHandler" });
@@ -53,7 +51,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
};
}
-export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
+export const beszelSystemsRequestHandler = createIntegrationRequestHandler<
BeszelSystemRow[],
"beszel" | "mock",
Record
@@ -64,7 +62,10 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
systems.map(async (system) => {
- const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
+ const details = await instance.getSystemDetailsAsync(system.id).catch((error) => {
+ logger.warn("Failed to fetch Beszel system details", { systemId: system.id, error: String(error) });
+ return null;
+ });
return mapToSystemRow(system, details);
}),
);
@@ -75,10 +76,6 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
});
return enriched;
},
- cacheDuration: dayjs.duration(1, "second"),
- fallbackToStaleOnError: true,
- retry: { attempts: 2, delayMs: 500 },
- queryKey: "beszelSystems",
});
export interface BeszelAlertsData {
@@ -86,7 +83,7 @@ export interface BeszelAlertsData {
history: BeszelAlertHistory[];
}
-export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
+export const beszelAlertsRequestHandler = createIntegrationRequestHandler<
BeszelAlertsData,
"beszel" | "mock",
{ includeHistory: boolean; maxHistoryItems: number }
@@ -104,10 +101,6 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
});
return { alerts, history };
},
- cacheDuration: dayjs.duration(1, "second"),
- fallbackToStaleOnError: true,
- retry: { attempts: 2, delayMs: 500 },
- queryKey: "beszelAlerts",
});
const timePeriodConfig: Record = {
@@ -124,7 +117,7 @@ export interface BeszelStatsData {
containerStats: BeszelContainerStatsRecord[];
}
-export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
+export const beszelStatsRequestHandler = createIntegrationRequestHandler<
BeszelStatsData,
"beszel" | "mock",
{ systemId: string; timePeriod: string; includeDocker: boolean }
@@ -135,7 +128,10 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
- ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
+ ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch((error) => {
+ logger.warn("Failed to fetch Beszel container stats", { systemId: input.systemId, error: String(error) });
+ return [];
+ })
: [];
logger.debug("beszelStats fetch completed", {
integrationId: integration.id,
@@ -147,8 +143,4 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
});
return { systemStats, containerStats };
},
- cacheDuration: dayjs.duration(1, "second"),
- fallbackToStaleOnError: true,
- retry: { attempts: 2, delayMs: 500 },
- queryKey: "beszelStats",
});
diff --git a/packages/request-handler/src/calendar.ts b/packages/request-handler/src/calendar.ts
index dd198912a..a358d13e8 100644
--- a/packages/request-handler/src/calendar.ts
+++ b/packages/request-handler/src/calendar.ts
@@ -4,9 +4,9 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler<
+export const calendarMonthRequestHandler = createIntegrationRequestHandler<
CalendarEvent[],
IntegrationKindByCategory<"calendar">,
{ year: number; month: number; releaseType: RadarrReleaseType[]; showUnmonitored: boolean }
@@ -23,6 +23,4 @@ export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler
input.showUnmonitored,
);
},
- cacheDuration: dayjs.duration(1, "minute"),
- queryKey: "calendarMonth",
});
diff --git a/packages/request-handler/src/coolify.ts b/packages/request-handler/src/coolify.ts
index d307a780f..25ddfe962 100644
--- a/packages/request-handler/src/coolify.ts
+++ b/packages/request-handler/src/coolify.ts
@@ -1,11 +1,9 @@
-import dayjs from "dayjs";
-
import { createIntegrationAsync } from "@homarr/integrations";
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const coolifyRequestHandler = createCachedIntegrationRequestHandler<
+export const coolifyRequestHandler = createIntegrationRequestHandler<
CoolifyInstanceInfo,
"coolify",
Record
@@ -14,6 +12,4 @@ export const coolifyRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getInstanceInfoAsync();
},
- cacheDuration: dayjs.duration(30, "seconds"),
- queryKey: "coolifyInfo",
});
diff --git a/packages/request-handler/src/dns-hole.ts b/packages/request-handler/src/dns-hole.ts
index a8c920801..85188c740 100644
--- a/packages/request-handler/src/dns-hole.ts
+++ b/packages/request-handler/src/dns-hole.ts
@@ -1,12 +1,10 @@
-import dayjs from "dayjs";
-
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler<
+export const dnsHoleRequestHandler = createIntegrationRequestHandler<
DnsHoleSummary,
IntegrationKindByCategory<"dnsHole">,
Record
@@ -15,6 +13,4 @@ export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
},
- cacheDuration: dayjs.duration(5, "seconds"),
- queryKey: "dnsHoleSummary",
});
diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts
index 2d228a598..12a78c05a 100644
--- a/packages/request-handler/src/docker.ts
+++ b/packages/request-handler/src/docker.ts
@@ -1,5 +1,4 @@
import type { Readable } from "node:stream";
-import dayjs from "dayjs";
import type { Container, ContainerInfo, ContainerStats } from "dockerode";
import type Dockerode from "dockerode";
@@ -13,7 +12,7 @@ import type { ContainerState, Port } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
-import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
+import { createWidgetRequestHandler } from "./lib/widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
@@ -171,16 +170,13 @@ const mockContainers: {
},
];
-export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
- queryKey: "dockerContainersResult",
- widgetKind: "dockerContainers",
+export const dockerContainersRequestHandler = createWidgetRequestHandler({
async requestAsync() {
if (isDemoMode) {
return mockContainers;
}
return await getContainersWithStatsAsync();
},
- cacheDuration: dayjs.duration(20, "seconds"),
});
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
diff --git a/packages/request-handler/src/downloads.ts b/packages/request-handler/src/downloads.ts
index 06d4e9c64..12106b07a 100644
--- a/packages/request-handler/src/downloads.ts
+++ b/packages/request-handler/src/downloads.ts
@@ -1,12 +1,10 @@
-import dayjs from "dayjs";
-
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const downloadClientRequestHandler = createCachedIntegrationRequestHandler<
+export const downloadClientRequestHandler = createIntegrationRequestHandler<
DownloadClientJobsAndStatus,
IntegrationKindByCategory<"downloadClient">,
{ limit: number }
@@ -15,6 +13,4 @@ export const downloadClientRequestHandler = createCachedIntegrationRequestHandle
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClientJobsAndStatusAsync(input);
},
- cacheDuration: dayjs.duration(5, "seconds"),
- queryKey: "downloadClientJobStatus",
});
diff --git a/packages/request-handler/src/duckduckgo-bangs.ts b/packages/request-handler/src/duckduckgo-bangs.ts
index a51bf5327..628e125a1 100644
--- a/packages/request-handler/src/duckduckgo-bangs.ts
+++ b/packages/request-handler/src/duckduckgo-bangs.ts
@@ -1,12 +1,10 @@
-import dayjs from "dayjs";
import { z } from "zod";
import { env } from "@homarr/common/env";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
-import { createChannelWithLatestAndEvents } from "@homarr/redis";
-import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
+import { createRequestHandler } from "@homarr/request-handler/lib/request-handler";
export type DuckDuckGoBang = z.infer;
@@ -38,9 +36,7 @@ const normalizeBangToken = (token: string) => token.toLowerCase().trim();
const compareBangTokenAsc = (left: DuckDuckGoBang, right: DuckDuckGoBang) => left.t.localeCompare(right.t);
-export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({
- queryKey: "duckduckgo-bangs",
- cacheDuration: dayjs.duration(1, "day"),
+export const duckDuckGoBangsRequestHandler = createRequestHandler({
async requestAsync(_) {
if (env.NO_EXTERNAL_CONNECTION) {
return [];
@@ -73,7 +69,4 @@ export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({
return normalized;
},
- createRedisChannel() {
- return createChannelWithLatestAndEvents("homarr:duckduckgo-bangs");
- },
});
diff --git a/packages/request-handler/src/firewall.ts b/packages/request-handler/src/firewall.ts
index 3754f1099..ad4f25b70 100644
--- a/packages/request-handler/src/firewall.ts
+++ b/packages/request-handler/src/firewall.ts
@@ -1,5 +1,3 @@
-import dayjs from "dayjs";
-
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
@@ -9,9 +7,9 @@ import type {
FirewallVersionSummary,
} from "@homarr/integrations/types";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler<
+export const firewallCpuRequestHandler = createIntegrationRequestHandler<
FirewallCpuSummary,
IntegrationKindByCategory<"firewall">,
Record
@@ -20,11 +18,9 @@ export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return integrationInstance.getFirewallCpuAsync();
},
- cacheDuration: dayjs.duration(5, "seconds"),
- queryKey: "firewallCpuSummary",
});
-export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandler<
+export const firewallMemoryRequestHandler = createIntegrationRequestHandler<
FirewallMemorySummary,
IntegrationKindByCategory<"firewall">,
Record
@@ -33,11 +29,9 @@ export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandle
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallMemoryAsync();
},
- cacheDuration: dayjs.duration(15, "seconds"),
- queryKey: "firewallMemorySummary",
});
-export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHandler<
+export const firewallInterfacesRequestHandler = createIntegrationRequestHandler<
FirewallInterfacesSummary[],
IntegrationKindByCategory<"firewall">,
Record
@@ -46,11 +40,9 @@ export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHa
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallInterfacesAsync();
},
- cacheDuration: dayjs.duration(30, "seconds"),
- queryKey: "firewallInterfacesSummary",
});
-export const firewallVersionRequestHandler = createCachedIntegrationRequestHandler<
+export const firewallVersionRequestHandler = createIntegrationRequestHandler<
FirewallVersionSummary,
IntegrationKindByCategory<"firewall">,
Record
@@ -59,6 +51,4 @@ export const firewallVersionRequestHandler = createCachedIntegrationRequestHandl
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallVersionAsync();
},
- cacheDuration: dayjs.duration(1, "hour"),
- queryKey: "firewallVersionSummary",
});
diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts
index 640eb6b97..604fff9da 100644
--- a/packages/request-handler/src/health-monitoring.ts
+++ b/packages/request-handler/src/health-monitoring.ts
@@ -1,12 +1,10 @@
-import dayjs from "dayjs";
-
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { ProxmoxClusterInfo, SystemHealthMonitoring } from "@homarr/integrations/types";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
+export const systemInfoRequestHandler = createIntegrationRequestHandler<
SystemHealthMonitoring,
Exclude, "proxmox" | "coolify" | "beszel">,
Record
@@ -15,11 +13,9 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSystemInfoAsync();
},
- cacheDuration: dayjs.duration(5, "seconds"),
- queryKey: "systemInfo",
});
-export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
+export const clusterInfoRequestHandler = createIntegrationRequestHandler<
ProxmoxClusterInfo,
"proxmox" | "mock",
Record
@@ -28,6 +24,4 @@ export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClusterInfoAsync();
},
- cacheDuration: dayjs.duration(5, "seconds"),
- queryKey: "clusterInfo",
});
diff --git a/packages/request-handler/src/immich.ts b/packages/request-handler/src/immich.ts
index e95cb32b8..56fc89bd0 100644
--- a/packages/request-handler/src/immich.ts
+++ b/packages/request-handler/src/immich.ts
@@ -1,12 +1,10 @@
-import dayjs from "dayjs";
-
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { ImmichAlbum, ImmichServerStats } from "@homarr/integrations";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const immichStatsRequestHandler = createCachedIntegrationRequestHandler<
+export const immichStatsRequestHandler = createIntegrationRequestHandler<
ImmichServerStats,
IntegrationKindByCategory<"photoService">,
Record
@@ -15,11 +13,9 @@ export const immichStatsRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getServerStatsAsync();
},
- cacheDuration: dayjs.duration(15, "minute"),
- queryKey: "immich-server-stats",
});
-export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler<
+export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
{
id: string;
albumName: string;
@@ -32,11 +28,9 @@ export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumsAsync();
},
- cacheDuration: dayjs.duration(15, "minute"),
- queryKey: "immich-albums",
});
-export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler<
+export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId: string }
@@ -45,6 +39,4 @@ export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumAsync(input.albumId);
},
- cacheDuration: dayjs.duration(15, "minute"),
- queryKey: "immich-album",
});
diff --git a/packages/request-handler/src/indexer-manager.ts b/packages/request-handler/src/indexer-manager.ts
index a957b32ad..f21a61f8f 100644
--- a/packages/request-handler/src/indexer-manager.ts
+++ b/packages/request-handler/src/indexer-manager.ts
@@ -1,12 +1,10 @@
-import dayjs from "dayjs";
-
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types";
-import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-export const indexerManagerRequestHandler = createCachedIntegrationRequestHandler<
+export const indexerManagerRequestHandler = createIntegrationRequestHandler<
Indexer[],
IntegrationKindByCategory<"indexerManager">,
Record
@@ -15,6 +13,4 @@ export const indexerManagerRequestHandler = createCachedIntegrationRequestHandle
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getIndexersAsync();
},
- cacheDuration: dayjs.duration(5, "minutes"),
- queryKey: "indexerManager",
});
diff --git a/packages/request-handler/src/lib/cached-integration-request-handler.ts b/packages/request-handler/src/lib/cached-integration-request-handler.ts
deleted file mode 100644
index aee69e39c..000000000
--- a/packages/request-handler/src/lib/cached-integration-request-handler.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { Duration } from "dayjs/plugin/duration";
-
-import type { Modify } from "@homarr/common/types";
-import type { Integration, IntegrationSecret } from "@homarr/db/schema";
-import type { IntegrationKind } from "@homarr/definitions";
-import { createIntegrationOptionsChannel } from "@homarr/redis";
-
-import { createCachedRequestHandler } from "./cached-request-handler";
-
-type IntegrationOfKind = Omit & {
- kind: TKind;
- decryptedSecrets: Modify, { value: string }>[];
- externalUrl: string | null;
-};
-
-interface Options> {
- queryKey: string;
- requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise;
- cacheDuration: Duration;
- cacheDurationForInput?: (input: TInput) => Duration | undefined;
- fallbackToStaleOnError?: boolean;
- retry?: { attempts?: number; delayMs?: number };
- isValid?: (data: TData) => boolean;
-}
-
-export const createCachedIntegrationRequestHandler = <
- TData,
- TKind extends IntegrationKind,
- TInput extends Record,
->(
- options: Options,
-) => {
- return {
- handler: (integration: IntegrationOfKind, itemOptions: TInput) =>
- createCachedRequestHandler({
- queryKey: options.queryKey,
- requestAsync: async (input: { options: TInput; integration: IntegrationOfKind }) => {
- return await options.requestAsync(input.integration, input.options);
- },
- cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
- fallbackToStaleOnError: options.fallbackToStaleOnError,
- retry: options.retry,
- isValid: options.isValid,
- createRedisChannel(input, handlerOptions) {
- return createIntegrationOptionsChannel(input.integration.id, handlerOptions.queryKey, input.options);
- },
- }).handler({ options: itemOptions, integration }),
- };
-};
diff --git a/packages/request-handler/src/lib/cached-request-handler.ts b/packages/request-handler/src/lib/cached-request-handler.ts
deleted file mode 100644
index c2342eed3..000000000
--- a/packages/request-handler/src/lib/cached-request-handler.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import dayjs from "dayjs";
-import type { Duration } from "dayjs/plugin/duration";
-
-import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-import type { createChannelWithLatestAndEvents } from "@homarr/redis";
-
-const logger = createLogger({ module: "cachedRequestHandler" });
-
-type FetchResult = { data: TData; timestamp: Date };
-type InFlightEntry = Promise>;
-
-const inFlightFetches = new Map();
-
-const isAbortedError = (error: unknown): boolean => {
- if (!(error instanceof Error)) return false;
- return error.name === "AbortError" || error.name === "TimeoutError";
-};
-
-interface Options> {
- // Unique key for this request handler
- queryKey: string;
- requestAsync: (input: TInput) => Promise;
- createRedisChannel: (
- input: TInput,
- options: Options,
- ) => ReturnType>;
- cacheDuration: Duration;
- fallbackToStaleOnError?: boolean;
- // Retry the upstream call on failure. attempts=1 means no retry (default).
- // AbortError / TimeoutError are never retried.
- retry?: { attempts?: number; delayMs?: number };
- // Predicate to decide whether a fetched response is worth caching.
- // Return false to skip the cache write (next call will refetch).
- isValid?: (data: TData) => boolean;
-}
-
-const defaultRetry = { attempts: 1, delayMs: 0 };
-const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
-export const createCachedRequestHandler = >(
- options: Options,
-) => {
- return {
- handler: (input: TInput) => {
- const channel = options.createRedisChannel(input, options);
- const retryConfig = { ...defaultRetry, ...options.retry };
-
- return {
- async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
- const channelData = await channel.getAsync();
-
- const requestNewDataAsync = (): Promise> => {
- const inflight = inFlightFetches.get(channel.name);
- if (inflight) {
- logger.debug("Cached request handler joining in-flight fetch", {
- channel: channel.name,
- queryKey: options.queryKey,
- });
- return inflight as Promise