Compare commits

..
Author SHA1 Message Date
ajnart e29ecf748a fix(icon-picker): keep URL visible in edit mode, add SVG-first ordering
Three UX bugs in the icon picker:

1. Skeleton-forever on edit screens. The input was pre-filled with the
   full CDN URL, the search query fired with that URL, and the DB only
   stores bare filenames, so the response was always empty -> 15
   skeletons, forever. Decouple the input display from the query:
   initialize search to the committed value and only fire the query
   when the user has actually typed something different.

2. Loading / error / no-results all looked identical (15 skeletons).
   Split the render into four explicit branches: isError (with retry),
   isLoading (skeletons, only on cold first load), results, no-results.

3. No relevance ranking and no SVG-first ordering. Add orderBy in the
   icons router: relevance tier (exact > prefix > substring) -> SVG
   first -> alphabetical. Patterns are bound as JS strings so the SQL
   works across sqlite / postgresql / mysql without concat operator
   differences.

Also add placeholderData: keepPreviousData so refetching during typing
doesn't flash the dropdown to skeletons, fix the upload pre-fill that
was leaking a local media URL into the search, drop the no-op
updateSelectedOptionIndex, and mark the file as a client component so
Next.js can render it from server-component routes.

Add common.iconPicker headerLoading / noResults / error keys to
en.json (other locales fall back to en until the next sync).

No ternaries in the changed files. Intended simplifications are marked
with ponytail: comments.
2026-06-22 12:41:04 +02:00
150 changed files with 4005 additions and 8365 deletions

No files matched your search

-1
View File
@@ -33,7 +33,6 @@ 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
-62
View File
@@ -163,65 +163,3 @@ jobs:
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
memory-regression:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-memory-test
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Start container and measure memory
run: |
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
# Wait for container to be healthy (up to 60s)
healthy=false
for i in $(seq 1 30); do
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs homarr-mem-test --tail 50
exit 1
fi
# Warm up: hit the dashboard page a few times
for i in $(seq 1 5); do
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
sleep 2
done
# Measure RSS after warmup
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
echo "Raw memory: $MEM_KB"
# Convert to numeric MB (handles GiB and MiB suffixes)
MEM_NUM=$(echo "$MEM_KB" | awk '{
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
else { print $1 / 1048576 }
}')
echo "Memory usage: ${MEM_NUM} MB"
MAX_MB=700
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
docker logs homarr-mem-test --tail 50
exit 1
fi
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.18.0
node-version: 24.16.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
+1 -1
View File
@@ -1,2 +1,2 @@
[tools]
node = "24.18.0"
node = "24.16.0"
+1 -1
View File
@@ -1 +1 @@
24.18.0
24.16.0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"oxc.oxc-vscode",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint",
"lokalise.i18n-ally"
-33
View File
@@ -1,36 +1,3 @@
## [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
+1 -187
View File
@@ -1,193 +1,7 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
# 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)
# Exempt Renovate‑managed files (no owners)
package.json
**/package.json
package-lock.json
+1 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.18.0-alpine AS base
FROM node:24.16.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -42,7 +42,6 @@ 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
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Version: 8.19.0](https://img.shields.io/badge/Version-8.19.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
![AppVersion: v1.60.0](https://img.shields.io/badge/AppVersion-v1.60.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -383,7 +383,6 @@ 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 |
@@ -396,6 +395,7 @@ 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 |
@@ -1,35 +0,0 @@
---
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 ".";
<IntegrationHeader integration={archiveTeamWarriorIntegration} categories={["archiving"]} />
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
<AddingIntegration />
### Secrets
ArchiveTeam Warrior does not require authentication by default. If your instance is protected with basic authentication, you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [],
},
]}
/>
@@ -1,9 +0,0 @@
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",
};
@@ -1,52 +0,0 @@
---
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';
<IntegrationHeader
integration={gluetunIntegration}
categories={['VPN', 'Network']}
/>
[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
<IntegrationCapabilites
items={[{
widget: vpnWidget,
}]}
/>
### 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
<AddingIntegration />
### 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.
<IntegrationSecrets secrets={[{
credentials: [],
steps: [],
},{
credentials: ['username', 'password'],
steps: [],
},{
credentials: ['apiKey'],
steps: [],
},,]} />
@@ -1,8 +0,0 @@
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",
};
@@ -1,42 +0,0 @@
---
title: 'Pushover'
description: "Pushover is a service for receiving real-time push notifications on your Android, iPhone, iPad, and Desktop devices."
hide_title: true
---
import { notificationsWidget } from '@site/docs/widgets/notifications';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { pushoverIntegration } from '.';
<IntegrationHeader
integration={pushoverIntegration}
categories={['Notifications', 'Messaging']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: notificationsWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['apiKey', 'userKey'],
steps: [
{
title: 'API Token (Application Key)',
description: 'Register your application on the [Pushover website](https://pushover.net/apps) and copy the API token/application key.'
},
{
title: 'User Key',
description: 'Your user key is viewable on the [Pushover dashboard](https://pushover.net/) when logged in. It is a 30-character string (e.g. `uQiRzpo4DXghDmr9QzzfQu27cmVRsG`).'
}
]
}]} />
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const pushoverIntegration: IntegrationDefinition = {
name: "Pushover",
description: "Pushover is a service for receiving real-time push notifications on your devices.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pushover.svg",
path: "../../integrations/pushover",
};
+1 -3
View File
@@ -63,9 +63,7 @@ import withoutIntegrations from './img/without-integrations.png';
### Interactions
- 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.
- View calendar events by clicking on the marked dates.
### Adding the widget
@@ -15,7 +15,6 @@ import { notificationsWidget } from '.';
import { ntfyIntegration } from '@site/docs/integrations/ntfy';
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud';
import { gotifyIntegration } from '@site/docs/integrations/gotify';
import { pushoverIntegration } from '@site/docs/integrations/pushover';
<WidgetHeader
widget={notificationsWidget}
@@ -36,8 +35,7 @@ import notificationsScreenshot from './img/notifications.png';
items={[
{ integration: ntfyIntegration },
{ integration: nextcloudIntegration },
{ integration: gotifyIntegration },
{ integration: pushoverIntegration }
{ integration: gotifyIntegration }
]}
/>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

-40
View File
@@ -1,40 +0,0 @@
---
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';
<WidgetHeader
widget={vpnWidget}
categories={['Network', 'Security']}
/>
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';
<div>
<img src={singleIntegrationImage} alt="VPN widget showing a single integration" width={128 * 3} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: gluetunIntegration,
}]} />
### Adding the widget
<AddingWidget />
-9
View File
@@ -1,9 +0,0 @@
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",
};
@@ -1,3 +1,4 @@
import { headers } from "next/headers";
import Image from "next/image";
import {
Accordion,
@@ -40,10 +41,11 @@ 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 { 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 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 classes from "./about.module.css";
export async function generateMetadata() {
@@ -54,10 +56,32 @@ 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 getDependenciesAsync();
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;
return (
<div>
@@ -161,7 +185,7 @@ export default async function AboutPage() {
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
.sort(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
@@ -1,42 +0,0 @@
"use client";
import { useEffect } from "react";
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export default function ManageErrorPage({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
const tError = useScopedI18n("management.error");
const t = useI18n();
useEffect(() => {
console.error(error);
}, [error]);
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={48} stroke={1.5} />
<Title order={3}>{tError("title")}</Title>
<Text size="sm" c="dimmed" maw={480} ta="center">
{tError("text")}
</Text>
{error.digest && (
<Text size="xs" c="dimmed">
{tError("digest", { digest: error.digest })}
</Text>
)}
<Button variant="light" onClick={unstable_retry}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Center>
);
}
@@ -10,7 +10,6 @@ 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";
@@ -80,20 +79,17 @@ export default async function ApiPage() {
const toolGroups = getMcpToolGroups();
return (
<>
<DynamicBreadcrumb />
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
</>
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
);
}
@@ -6,7 +6,6 @@ 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";
@@ -32,13 +31,10 @@ export default async function BackupPage() {
const t = await getScopedI18n("management.page.tool.backup");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
</>
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
);
}
@@ -149,20 +149,16 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
},
];
interface DockerTableProps {
initialData?: RouterOutputs["docker"]["getContainers"];
}
export function DockerTable({ initialData }: DockerTableProps) {
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData: { containers, timestamp },
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const relativeTime = useTimeAgo(data.timestamp);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
@@ -180,11 +176,8 @@ export function DockerTable({ initialData }: DockerTableProps) {
},
});
const containers = data?.containers ?? [];
const table = useTranslatedMantineReactTable({
data: containers,
state: { isLoading: isFetching },
data: data.containers,
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
@@ -197,7 +190,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }),
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
style: { minWidth: 300 },
autoFocus: true,
},
@@ -23,7 +23,7 @@ export default async function DockerPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable initialData={{ containers, timestamp }} />
<DockerTable containers={containers} timestamp={timestamp} />
</Stack>
</>
);
@@ -7,7 +7,6 @@ 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 {
@@ -25,7 +24,6 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
return (
<ManageContainer size="xl">
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, group.name]])} />
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
@@ -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);
@@ -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);
@@ -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);
@@ -211,17 +211,7 @@ describe("SQLite backup", () => {
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
// 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);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
sqlite.close();
});
@@ -142,8 +142,7 @@ 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 },
);
}
@@ -1,5 +1,3 @@
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";
@@ -18,7 +16,6 @@ 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;
@@ -33,33 +30,30 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
return (
<>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
</Card>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -89,7 +83,6 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -41,12 +41,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
return itemA.name.localeCompare(itemB.name);
}),
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
[t],
);
@@ -1,250 +0,0 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Menu, Switch } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
import { 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<Record<string, unknown> | 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 options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
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<string, unknown>) => {
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 (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
<>
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
);
};
@@ -10,8 +10,3 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.itemCard:global(.iframe-wrapper) {
background-color: transparent !important;
border-color: transparent !important;
}
@@ -57,7 +57,6 @@ export const categoryTranslationKeys: Record<string, string> = {
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",
@@ -1,10 +0,0 @@
"use client";
import { createModal } from "@homarr/modals";
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
defaultTitle: (t) => t("docker.title"),
size: 1400,
});
@@ -3,7 +3,6 @@ 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";
@@ -12,10 +11,9 @@ 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 (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
@@ -14,8 +14,6 @@ interface DynamicBreadcrumbProps {
nonInteractable?: string[];
}
const categorySegments = new Set(["tools"]);
/**
* Breadcrumb is client side rendered. Elements are automatically
* calculated and translated using dynamic keys.
@@ -62,11 +60,8 @@ 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 (isNonInteractable) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
@@ -76,7 +71,7 @@ export const DynamicBreadcrumb = ({
);
}
if (isNonInteractable) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
}
@@ -5,15 +5,7 @@ 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 {
IconBrandDocker,
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -25,16 +17,14 @@ 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<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
isDockerEnabled?: boolean;
}
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -44,7 +34,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
await signOut({
@@ -91,11 +80,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{t("docker")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
+7 -15
View File
@@ -35,22 +35,14 @@ export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> =
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
const workspaceConfigContent = await fsPromises.readFile(
path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
"utf-8",
);
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
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;
}
return new Map(Object.entries(parseResult.catalog ?? {}));
};
const workspaceConfigCatalogSchema = z.object({
-1
View File
@@ -3,7 +3,6 @@
"compilerOptions": {
"paths": {
"~/*": ["./src/*"],
"@static-data/*": ["../../static-data/*"],
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
},
"plugins": [
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.0-trixie AS base
FROM node:24.16.0-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
+3 -3
View File
@@ -5,8 +5,8 @@
"better-sqlite3": "^12.10.0"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
},
"packageManager": "pnpm@11.9.0"
"packageManager": "pnpm@11.6.0"
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.68.1",
"version": "1.67.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -61,10 +61,10 @@
"vitest": "catalog:"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
},
"packageManager": "pnpm@11.9.0",
"packageManager": "pnpm@11.7.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
+5 -109
View File
@@ -2,7 +2,6 @@ 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 {
@@ -15,8 +14,6 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "beszelRouter" });
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -28,9 +25,7 @@ export const beszelRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
const settled = await Promise.allSettled(
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
@@ -45,29 +40,6 @@ export const beszelRouter = createTRPCRouter({
};
}),
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getSystems integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
integrationUrl: integration?.url ?? "",
systems: [],
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getSystems completed", {
userId: ctx.session?.user?.id,
integrationIds,
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
return results;
}),
@@ -111,14 +83,7 @@ export const beszelRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getAlerts called", {
userId: ctx.session?.user?.id,
integrationIds,
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const settled = await Promise.allSettled(
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
@@ -135,7 +100,6 @@ export const beszelRouter = createTRPCRouter({
}
return {
integrationId: integration.id,
integrationName: integration.name,
alerts: alertsResult.data.alerts,
history: alertsResult.data.history,
systemNameMap,
@@ -143,30 +107,6 @@ export const beszelRouter = createTRPCRouter({
};
}),
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getAlerts integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
alerts: [],
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getAlerts completed", {
userId: ctx.session?.user?.id,
integrationIds,
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
return results;
}),
@@ -224,41 +164,13 @@ export const beszelRouter = createTRPCRouter({
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
logger.debug("getSystemStats called", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
try {
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
logger.debug("getSystemStats completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
return { integrationId: integration.id, ...data, updatedAt: timestamp };
} catch (error) {
logger.warn("getSystemStats failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
error,
});
return {
integrationId: integration.id,
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: error instanceof Error ? error.message : String(error),
};
}
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
}),
subscribeSystemStats: publicProcedure
@@ -278,11 +190,6 @@ export const beszelRouter = createTRPCRouter({
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();
@@ -292,12 +199,6 @@ export const beszelRouter = createTRPCRouter({
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 {
@@ -309,11 +210,6 @@ export const beszelRouter = createTRPCRouter({
return () => {
abortController.abort();
logger.debug("subscribeSystemStats closed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
};
});
}),
-1
View File
@@ -36,7 +36,6 @@ 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)),
});
-59
View File
@@ -1,59 +0,0 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createVpnIntegrationMiddleware = () =>
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = vpnSummaryHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
}),
subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"vpn"> }>;
summary: VpnSummary | null;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});
-2
View File
@@ -21,7 +21,6 @@ import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
import { vpnJob } from "./jobs/integrations/vpn";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -58,7 +57,6 @@ const getJobGroup = () => {
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
vpn: vpnJob,
});
};
@@ -1,14 +0,0 @@
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(vpnSummaryHandler.handler, {
widgetKinds: ["vpn"],
getInput: {
vpn: () => ({}),
},
}),
);
@@ -165,7 +165,6 @@ 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"
@@ -180,7 +179,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/github"
| "/docs/integrations/gitlab"
| "/docs/integrations/glances"
| "/docs/integrations/gluetun"
| "/docs/integrations/gotify"
| "/docs/integrations/home-assistant"
| "/docs/integrations/ical"
@@ -282,7 +280,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/video"
| "/docs/widgets/vpn"
| "/docs/widgets/weather"
| ""
| "/sitemap.xml"
@@ -326,7 +323,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/quay"
| "/docs/integrations/ntfy"
| "/docs/integrations/gotify"
| "/docs/integrations/pushover"
| "/docs/integrations/ical"
| "/docs/integrations/anchor"
| "/docs/integrations/truenas"
@@ -341,7 +337,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/navidrome"
| "/docs/integrations/umami"
| "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/widgets/clock"
@@ -385,7 +380,6 @@ 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"
@@ -41,7 +41,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
quay: "quay",
ntfy: "ntfy",
gotify: "gotify",
pushover: "pushover",
ical: "ical",
anchor: "anchor",
truenas: "truenas",
@@ -56,7 +55,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
@@ -43,7 +43,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
uptimeKuma: "uptime-kuma",
audioStats: "audio-stats",
umami: "umami",
vpn: "vpn",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
+1 -18
View File
@@ -18,7 +18,6 @@ export const integrationSecretKindObject = {
githubAppId: { isPublic: true, multiline: false },
githubInstallationId: { isPublic: true, multiline: false },
slug: { isPublic: true, multiline: false },
userKey: { isPublic: false, multiline: false },
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
@@ -363,14 +362,6 @@ export const integrationDefs = {
category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/gotify"),
},
pushover: {
name: "Pushover",
secretKinds: [["apiKey", "userKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pushover.svg",
category: ["notifications"],
defaultUrl: "https://api.pushover.net",
documentationUrl: createDocumentationLink("/docs/integrations/pushover"),
},
ical: {
name: "iCal",
secretKinds: [["url"]],
@@ -498,20 +489,13 @@ 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: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
documentationUrl: null,
},
// 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: {
@@ -620,7 +604,6 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"vpn",
"archiving",
"ups",
"documents",
@@ -38,7 +38,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
vpn: getIntegrationKindsByCategory("vpn"),
anchorNote: ["anchor"],
};
-1
View File
@@ -41,7 +41,6 @@ export const widgetKinds = [
"uptimeKuma",
"audioStats",
"umami",
"vpn",
"archiveTeamWarrior",
"ups",
"beszelSystemTable",
@@ -21,7 +21,6 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { GlancesIntegration } from "../glances/glances-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";
@@ -39,7 +38,6 @@ 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 { PushoverIntegration } from "../pushover/pushover-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
@@ -126,7 +124,6 @@ export const integrationCreators = {
quay: QuayIntegration,
ntfy: NTFYIntegration,
gotify: GotifyIntegration,
pushover: PushoverIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
unraid: UnraidIntegration,
@@ -140,7 +137,6 @@ export const integrationCreators = {
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
@@ -46,13 +46,10 @@ export class BeszelIntegration extends Integration {
private async authenticateAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync();
if (existingSession) {
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
return existingSession;
}
const authUrl = this.url("/api/collections/users/auth-with-password");
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -60,28 +57,23 @@ export class BeszelIntegration extends Integration {
password: this.getSecretValue("password"),
}),
});
logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status });
if (!response.ok) {
logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status });
throw new ResponseError(response);
}
const data = (await response.json()) as BeszelAuthResponse;
const session: BeszelSession = { token: data.token, userId: data.record.id };
await this.sessionStore.setAsync(session);
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
return session;
}
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
const method = options.method ?? "GET";
const start = performance.now();
let session = await this.authenticateAsync();
const doFetch = (token: string) =>
fetchWithTrustedCertificatesAsync(url, {
method,
method: options.method ?? "GET",
headers: {
Authorization: token,
...(options.body ? { "Content-Type": "application/json" } : {}),
@@ -90,42 +82,14 @@ export class BeszelIntegration extends Integration {
});
let response = await doFetch(session.token);
logger.debug("Beszel response", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
attempt: 1,
});
if (response.status === 401) {
logger.warn("Beszel 401, clearing session and retrying", {
integrationId: this.integration.id,
method,
path: url.pathname,
});
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await doFetch(session.token);
logger.debug("Beszel response (after re-auth)", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
attempt: 2,
});
}
if (!response.ok) {
logger.warn("Beszel request failed", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
});
throw new ResponseError(response);
}
@@ -293,7 +257,6 @@ export class BeszelIntegration extends Integration {
): Promise<void> {
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 },
@@ -301,14 +264,8 @@ export class BeszelIntegration extends Integration {
});
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();
@@ -365,10 +322,6 @@ export class BeszelIntegration extends Integration {
}
}
if (!signal.aborted) {
logger.warn("Beszel SSE stream ended unexpectedly", {
integrationId: this.integration.id,
systemId,
});
throw new Error("Beszel SSE stream ended unexpectedly");
}
} finally {
@@ -35,7 +35,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
if (session == null) throw new Error("Session was unexpectitly null");
const [stats, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]);
const stats = await this.getAllStatsAsync();
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,
cpuTemp: undefined,
gpu: stats.gpu.map((gpu) => ({
gpuId: gpu.gpu_id,
name: gpu.name,
@@ -80,23 +80,6 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
return await response.text();
}
private async getCpuTempAsync(): Promise<number | undefined> {
// 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"));
@@ -190,33 +173,3 @@ 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<typeof sensorsSchema>): 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;
};
@@ -1,115 +0,0 @@
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<TestingResult> {
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<VpnSummary> {
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<string, string>): Promise<GluetunVpnStatus> {
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<string, string>): Promise<GluetunDnsStatus> {
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<string, string>): Promise<GluetunPublicIp> {
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<string, string>): Promise<GluetunVpnSettings> {
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<string, string> {
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 {};
}
}
@@ -1,116 +0,0 @@
import { z } from "zod/v4";
export const gluetunVpnStatusSchema = z.object({
status: z.string(),
});
export type GluetunVpnStatus = z.infer<typeof gluetunVpnStatusSchema>;
export const gluetunDnsStatusSchema = gluetunVpnStatusSchema;
export type GluetunDnsStatus = z.infer<typeof gluetunDnsStatusSchema>;
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<typeof gluetunPublicIpSchema>;
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<typeof gluetunVpnSettingsSchema>;
@@ -1,371 +0,0 @@
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<typeof import("@homarr/db")>();
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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
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<string, string>;
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<string, string>;
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<string, string>;
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<string, string>;
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();
});
});
@@ -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,13 +31,11 @@ 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,
}));
}
}
-1
View File
@@ -19,7 +19,6 @@ export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integratio
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
export { GotifyIntegration } from "./gotify/gotify-integration";
export { PushoverIntegration } from "./pushover/pushover-integration";
export { NTFYIntegration } from "./ntfy/ntfy-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { GlancesIntegration } from "./glances/glances-integration";
@@ -1,5 +0,0 @@
import type { VpnSummary } from "./vpn-summary-types";
export interface VpnSummaryIntegration {
getSummaryAsync(): Promise<VpnSummary>;
}
@@ -1,13 +0,0 @@
export interface VpnProviderDetails {
protocol: string;
provider: string;
}
export interface VpnSummary {
vpnStatus: string;
dnsStatus: string;
publicIp: string;
country: string;
city: string;
vpnProvider: VpnProviderDetails;
}
@@ -69,24 +69,36 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
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) {
const externalLink = lidarrExternalCalendarLinks[link.name];
if (!externalLink) {
continue;
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;
}
links.push({ ...externalLink, href: link.url });
}
return links;
@@ -97,7 +109,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => {
const flatImages = [...event.images];
const sortedImages = flatImages.toSorted(
const sortedImages = flatImages.sort(
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
@@ -137,16 +149,6 @@ 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(),
titleSlug: z.string().optional(),
}),
artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }),
releaseDate: z.string().transform((value) => new Date(value)),
});
const lidarrExternalCalendarLinks: Record<string, Omit<CalendarLink, "href">> = {
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" },
};
@@ -127,18 +127,11 @@ 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,
subtitle: title === item.title ? item.tagline : item.title,
title: item.title,
subtitle: item.tagline,
description: item.summary,
releaseDate: item.originallyAvailableAt
? new Date(item.originallyAvailableAt)
@@ -229,8 +222,6 @@ 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(),
@@ -1,140 +0,0 @@
// @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<string, unknown>) => ({
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<typeof fetchWithTrustedCertificatesAsync>;
}
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<typeof fetchWithTrustedCertificatesAsync>;
}
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",
},
]);
});
});
@@ -1,38 +0,0 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { Integration } from "../base/integration";
import type { IntegrationTestingInput } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { Notification } from "../interfaces/notifications/notification-types";
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import { pushoverLimitsResponseSchema } from "./pushover-schema";
export class PushoverIntegration extends Integration implements INotificationsIntegration {
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const token = this.getSecretValue("apiKey");
const url = this.url("/1/apps/limits.json", { token });
const response = await input.fetchAsync(url);
if (!response.ok) throw new ResponseError(response);
const result = await pushoverLimitsResponseSchema.safeParseAsync(await response.json());
if (!result.success) throw new Error(`Failed to parse Pushover response: ${result.error.message}`);
if (result.data.status !== 1) throw new Error("Pushover API returned non-success status");
return { success: true };
}
public async getNotificationsAsync(): Promise<Notification[]> {
const token = this.getSecretValue("apiKey");
const url = this.url("/1/apps/limits.json", { token });
const response = await fetchWithTrustedCertificatesAsync(url);
if (!response.ok) throw new ResponseError(response);
const result = await pushoverLimitsResponseSchema.safeParseAsync(await response.json());
if (!result.success) throw new Error(`Failed to parse Pushover response: ${result.error.message}`);
return [];
}
}
@@ -1,9 +0,0 @@
import { z } from "zod/v4";
export const pushoverLimitsResponseSchema = z.object({
status: z.number(),
request: z.string(),
limit: z.number().optional(),
remaining: z.number().optional(),
reset: z.number().optional(),
});
-2
View File
@@ -4,7 +4,6 @@ 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";
@@ -23,5 +22,4 @@ 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";
@@ -1,11 +1,11 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration";
import { GlancesIntegration } from "../src/glances/glances-integration";
// ─── module mocks ────────────────────────────────────────────────────────────
@@ -64,19 +64,6 @@ 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",
@@ -88,12 +75,8 @@ const integrationInput = {
// ─── tests ───────────────────────────────────────────────────────────────────
describe("GlancesIntegration schema", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("parses stats when quicklook is present", async () => {
mockGlancesFetch({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }, []);
mockFetch.mockResolvedValueOnce(makeResponse({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }));
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -103,7 +86,7 @@ describe("GlancesIntegration schema", () => {
});
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
mockGlancesFetch(baseStats, []);
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -112,41 +95,9 @@ describe("GlancesIntegration schema", () => {
});
test("does not throw a Zod parse error when quicklook is missing", async () => {
mockGlancesFetch(baseStats, []);
mockFetch.mockResolvedValueOnce(makeResponse(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);
});
});
@@ -1,76 +0,0 @@
// @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<typeof import("@homarr/db")>();
return { ...actual, db: createDb() };
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
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",
}),
]),
);
});
});
+211 -158
View File
@@ -2,7 +2,15 @@
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";
@@ -23,10 +31,15 @@ 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<
typeof import("@homarr/core/infrastructure/certificates")
>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
getTrustedCertificateHostnamesAsync: vi
.fn()
.mockImplementation(() => Promise.resolve([])),
};
});
@@ -77,7 +90,9 @@ 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",
@@ -94,7 +109,10 @@ 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<string> {
async function fetchApiToken(
baseUrl: string,
apiVersion: TechnitiumVersion,
): Promise<string> {
if (apiVersion === "v15") {
// v15 exposes a dedicated endpoint for non-expiring API tokens.
const res = await fetch(
@@ -104,7 +122,9 @@ async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Pr
}
// 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;
}
@@ -124,12 +144,16 @@ const pool: Record<string, ContainerEntry> = {};
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);
@@ -139,209 +163,238 @@ 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);
});
},
);
+2 -12
View File
@@ -415,18 +415,8 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
});
},
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),
});
}
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
},
setAsync: async (data: TData) => {
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
+16 -42
View File
@@ -1,6 +1,5 @@
import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
BeszelAlert,
@@ -14,8 +13,6 @@ import type {
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const logger = createLogger({ module: "beszelRequestHandler" });
export type { BeszelSystemRow } from "@homarr/integrations/types";
/**
@@ -59,7 +56,6 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
Record<string, never>
>({
async requestAsync(integration) {
const start = performance.now();
const instance = await createIntegrationAsync(integration);
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
@@ -68,16 +64,9 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
return mapToSystemRow(system, details);
}),
);
logger.debug("beszelSystems fetch completed", {
integrationId: integration.id,
durationMs: Math.round(performance.now() - start),
count: enriched.length,
});
return enriched;
},
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "beszelSystems",
});
@@ -92,31 +81,22 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
{ includeHistory: boolean; maxHistoryItems: number }
>({
async requestAsync(integration, input) {
const start = performance.now();
const instance = await createIntegrationAsync(integration);
const alerts = await instance.getAlertsAsync();
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
logger.debug("beszelAlerts fetch completed", {
integrationId: integration.id,
durationMs: Math.round(performance.now() - start),
alertsCount: alerts.length,
historyCount: history.length,
});
return { alerts, history };
},
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
cacheDuration: dayjs.duration(15, "seconds"),
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"1m": { type: "1m", perPage: 60 },
"1h": { type: "1m", perPage: 60 },
"12h": { type: "10m", perPage: 72 },
"24h": { type: "20m", perPage: 72 },
"1w": { type: "480m", perPage: 21 },
"30d": { type: "480m", perPage: 90 },
const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
"1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
"1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
"30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
};
export interface BeszelStatsData {
@@ -130,25 +110,19 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
async requestAsync(integration, input) {
const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
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(() => [])
: [];
logger.debug("beszelStats fetch completed", {
integrationId: integration.id,
systemId: input.systemId,
timePeriod: input.timePeriod,
durationMs: Math.round(performance.now() - start),
systemStatsCount: systemStats.length,
containerStatsCount: containerStats.length,
});
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
cacheDuration: dayjs.duration(60, "seconds"),
queryKey: "beszelStats",
cacheDurationForInput(input) {
const config = timePeriodConfig[input.timePeriod];
if (!config) return undefined;
return dayjs.duration(config.cacheSeconds, "seconds");
},
});
@@ -18,9 +18,6 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheDuration: Duration;
cacheDurationForInput?: (input: TInput) => Duration | undefined;
fallbackToStaleOnError?: boolean;
retry?: { attempts?: number; delayMs?: number };
isValid?: (data: TData) => boolean;
}
export const createCachedIntegrationRequestHandler = <
@@ -38,9 +35,6 @@ export const createCachedIntegrationRequestHandler = <
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<TData>(input.integration.id, handlerOptions.queryKey, input.options);
},
@@ -7,16 +7,6 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis";
const logger = createLogger({ module: "cachedRequestHandler" });
type FetchResult<TData> = { data: TData; timestamp: Date };
type InFlightEntry = Promise<FetchResult<unknown>>;
const inFlightFetches = new Map<string, InFlightEntry>();
const isAbortedError = (error: unknown): boolean => {
if (!(error instanceof Error)) return false;
return error.name === "AbortError" || error.name === "TimeoutError";
};
interface Options<TData, TInput extends Record<string, unknown>> {
// Unique key for this request handler
queryKey: string;
@@ -27,70 +17,28 @@ interface Options<TData, TInput extends Record<string, unknown>> {
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
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<void>((resolve) => setTimeout(resolve, ms));
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
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<FetchResult<TData>> => {
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<FetchResult<TData>>;
}
const promise = (async (): Promise<FetchResult<TData>> => {
let lastError: unknown;
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
try {
const data = await options.requestAsync(input);
if (options.isValid && !options.isValid(data)) {
logger.warn("Cached request handler received invalid data, not caching", {
channel: channel.name,
queryKey: options.queryKey,
attempt,
});
return { data, timestamp: new Date() };
}
await channel.publishAndUpdateLastStateAsync(data);
return { data, timestamp: new Date() };
} catch (error) {
lastError = error;
if (isAbortedError(error) || attempt === retryConfig.attempts) {
break;
}
logger.warn("Cached request handler fetch failed, retrying", {
channel: channel.name,
queryKey: options.queryKey,
attempt,
nextDelayMs: retryConfig.delayMs,
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
});
await sleep(retryConfig.delayMs);
}
}
const requestNewDataAsync = async () => {
try {
const data = await options.requestAsync(input);
await channel.publishAndUpdateLastStateAsync(data);
return {
data,
timestamp: new Date(),
};
} catch (error) {
if (options.fallbackToStaleOnError && channelData) {
logger.warn(
new ErrorWithMetadata(
@@ -98,21 +46,15 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
{
channel: channel.name,
queryKey: options.queryKey,
attempts: retryConfig.attempts,
},
{ cause: lastError },
{ cause: error },
),
);
return channelData;
}
throw lastError;
})().finally(() => {
inFlightFetches.delete(channel.name);
});
inFlightFetches.set(channel.name, promise as InFlightEntry);
return promise;
throw error;
}
};
if (forceUpdate) {
-35
View File
@@ -1,35 +0,0 @@
import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const logger = createLogger({ module: "vpnRequestHandler" });
export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
VpnSummary | null,
IntegrationKindByCategory<"vpn">,
Record<string, never>
>({
async requestAsync(integration, _input) {
try {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
} catch (error) {
// The VPN control server is unreachable (e.g. the container is down).
// Return null so the widget can render an "unavailable" status instead of
// surfacing a hard error, and so it recovers automatically once it is back up.
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
integrationId: integration.id,
integrationKind: integration.kind,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "vpnSummary",
});
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "",
"logout": "",
"login": "",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "这里",
"iconPicker": {
"label": "图标 URL",
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。"
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "切换到暗黑模式",
"switchToLightMode": "切换到日间模式",
"management": "管理中心",
"docker": "",
"preferences": "偏好设置",
"logout": "退出登录",
"login": "登录",
@@ -2357,9 +2352,6 @@
"name": "Docker 状态",
"description": "您的容器状态 (这个小部件只能用管理员权限添加)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "启用项目排序"
},
@@ -2413,12 +2405,6 @@
"title": "实例"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "未找到",
"text": "找不到请求的资源"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "首页",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "Umami 活跃访客"
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "获取 Docker 容器失败",
"noColumns": ""
"internalServerError": "获取 Docker 容器失败"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "crwdns4473:0crwdne4473:0",
"speedtest": "crwdns4475:0crwdne4475:0",
"analytics": "crwdns4477:0crwdne4477:0",
"vpn": "crwdns5687:0crwdne5687:0",
"mediaLibrary": "crwdns4979:0crwdne4979:0",
"uptimeMonitoring": "crwdns4981:0crwdne4981:0"
},
@@ -1413,10 +1412,7 @@
"here": "crwdns552:0crwdne552:0",
"iconPicker": {
"label": "crwdns554:0crwdne554:0",
"header": "crwdns556:0{countIcons}crwdne556:0",
"headerLoading": "crwdns5681:0crwdne5681:0",
"noResults": "crwdns5683:0{search}crwdne5683:0",
"error": "crwdns5685:0crwdne5685:0"
"header": "crwdns556:0{countIcons}crwdne556:0"
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "crwdns594:0crwdne594:0",
"switchToLightMode": "crwdns596:0crwdne596:0",
"management": "crwdns598:0crwdne598:0",
"docker": "crwdns5703:0crwdne5703:0",
"preferences": "crwdns600:0crwdne600:0",
"logout": "crwdns602:0crwdne602:0",
"login": "crwdns604:0crwdne604:0",
@@ -2357,9 +2352,6 @@
"name": "crwdns3424:0crwdne3424:0",
"description": "crwdns3426:0crwdne3426:0",
"option": {
"columns": {
"label": "crwdns5705:0crwdne5705:0"
},
"enableRowSorting": {
"label": "crwdns3808:0crwdne3808:0"
},
@@ -2413,12 +2405,6 @@
"title": "crwdns3876:0crwdne3876:0"
}
},
"vpn": {
"name": "crwdns5689:0crwdne5689:0",
"description": "crwdns5691:0crwdne5691:0",
"serviceUnavailable": "crwdns5693:0crwdne5693:0",
"option": {}
},
"ups": {
"name": "crwdns4481:0crwdne4481:0",
"description": "crwdns4483:0crwdne4483:0",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": "crwdns5653:0crwdne5653:0"
},
"empty": {
"noSystems": "crwdns5677:0crwdne5677:0"
},
"metric": {
"cpu": "crwdns4865:0crwdne4865:0",
"memory": "crwdns4867:0crwdne4867:0",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "crwdns4927:0crwdne4927:0",
"empty": {
"noSystems": "crwdns5679:0crwdne5679:0"
},
"error": {
"internalServerError": "crwdns5661:0crwdne5661:0",
"liveConnectionFailed": "crwdns5663:0crwdne5663:0",
@@ -4153,11 +4133,6 @@
"title": "crwdns1568:0crwdne1568:0",
"text": "crwdns1570:0crwdne1570:0"
},
"error": {
"title": "crwdns5697:0crwdne5697:0",
"text": "crwdns5699:0crwdne5699:0",
"digest": "crwdns5701:0{digest}crwdne5701:0"
},
"navbar": {
"items": {
"home": "crwdns1572:0crwdne1572:0",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "crwdns4223:0crwdne4223:0"
},
"vpn": {
"label": "crwdns5695:0crwdne5695:0"
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "crwdns2262:0crwdne2262:0",
"noColumns": "crwdns5707:0crwdne5707:0"
"internalServerError": "crwdns2262:0crwdne2262:0"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "zde",
"iconPicker": {
"label": "URL ikony",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Přepnout do tmavého režimu",
"switchToLightMode": "Přepnout do světlého režimu",
"management": "Správa",
"docker": "",
"preferences": "Vaše předvolby",
"logout": "Odhlásit se",
"login": "Přihlásit se",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": "Nepodařilo se nalézt požadovaný zdroj"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Domovská stránka",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "her",
"iconPicker": {
"label": "Ikon-URL",
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Skift til mørk tilstand",
"switchToLightMode": "Skift til lys tilstand",
"management": "Håndtering",
"docker": "",
"preferences": "Dine indstillinger",
"logout": "Log ud",
"login": "Log ind",
@@ -2357,9 +2352,6 @@
"name": "Docker statistik",
"description": "Statistik for dine containere (Denne widget kan kun tilføjes med administratorrettigheder)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Aktiver sortering"
},
@@ -2413,12 +2405,6 @@
"title": "Instanser"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Ikke Fundet",
"text": "Kunne ikke finde den anmodede ressource"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Hjem",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Kunne ikke hente Docker-containere",
"noColumns": ""
"internalServerError": "Kunne ikke hente Docker-containere"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "Hier",
"iconPicker": {
"label": "",
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Zum dunklen Design wechseln",
"switchToLightMode": "Zum hellen Design wechseln",
"management": "Verwaltung",
"docker": "",
"preferences": "Ihre Einstellungen",
"logout": "Abmelden",
"login": "Anmelden",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Nicht gefunden",
"text": "Konnte die angeforderte Ressource nicht finden"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Startseite",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Fehler beim Abrufen der Docker Container",
"noColumns": ""
"internalServerError": "Fehler beim Abrufen der Docker Container"
}
},
"kubernetes": {
+80 -109
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Überwachung",
"speedtest": "Geschwindigkeitstest",
"analytics": "Nutzungsdaten",
"vpn": "",
"mediaLibrary": "Medienbibliothek",
"uptimeMonitoring": "Überwachung der Betriebszeit"
},
@@ -1413,10 +1412,7 @@
"here": "Hier",
"iconPicker": {
"label": "Icon URL",
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
"headerLoading": "Tippe um die Symbole zu durchsuchen…",
"noResults": "Keine Symbole stimmen mit \"{search}\" überein.",
"error": "Konnte Symbole nicht laden."
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Zum dunklen Design wechseln",
"switchToLightMode": "Zum hellen Design wechseln",
"management": "Verwaltung",
"docker": "",
"preferences": "Ihre Einstellungen",
"logout": "Abmelden",
"login": "Anmelden",
@@ -2357,9 +2352,6 @@
"name": "Docker Statistiken",
"description": "Statistiken Ihrer Container (Dieses Widget kann nur mit Administratorrechten hinzugefügt werden)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Sortierung von Elementen aktivieren"
},
@@ -2413,12 +2405,6 @@
"title": "Instanzen"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "UPS",
"description": "Überwachen Sie den Status Ihrer UPS Geräte über einen NUT Server",
@@ -3388,74 +3374,74 @@
"pausedCount": "Pausiert"
},
"error": {
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
"internalServerError": ""
}
},
"audioStats": {
"name": "Audiostatistiken",
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
"description": "",
"option": {
"showArtists": {
"label": "Künstler anzeigen",
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
"description": ""
},
"showAlbums": {
"label": "Alben anzeigen",
"label": "",
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
},
"showSongs": {
"label": "Songs anzeigen",
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
"description": ""
},
"showNowPlaying": {
"label": "\"Sie hören\" anzeigen",
"description": "Zeige aktuell gespielte Titel an (Navidrome)"
"label": "",
"description": ""
},
"showNowPlayingList": {
"label": "\"Sie hören jetzt\" Wiedergabeliste anzeigen",
"description": "Einzelne Tracks anstelle der aktiven Streamanzahl anzeigen (Navidrome)"
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "Maximale jetzt abgespielte Elemente",
"description": "Maximale Anzahl an Titeln, die in der \"Sie hören jetzt\" Wiedergabeliste angezeigt werden (Navidrome)"
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "Anzahl der Bibliotheken anzeigen",
"description": "Zeige die Anzahl der Bibliotheken in deiner Instanz (Hörbücherregal)"
"label": "",
"description": ""
},
"showAudiobooks": {
"label": "Hörbücher anzeigen",
"description": "Zeige die Gesamtzahl der Hörbücher in allen Bibliotheken (Hörbücherregal)"
"label": "",
"description": ""
},
"showPodcasts": {
"label": "Podcasts anzeigen",
"description": "Zeige die Gesamtzahl der Podcasts in allen Bibliotheken (Hörbücherregal)"
"label": "",
"description": ""
},
"showListeningTime": {
"label": "Hördauer anzeigen",
"label": "",
"description": "Zeigt deine gesamte Hörzeit (Audiobookshelf)"
},
"showActiveSessions": {
"label": "Aktive Streams anzeigen",
"description": "Zeige die Anzahl der aktuell aktiven Wiedergabesitzungen (Hörbücherregal)"
"description": ""
},
"compactMode": {
"label": "Kompakter Modus",
"description": "Verwenden Sie ein dichteres Layout mit kleineren Kacheln, ideal für kleine Widget-Größen"
"label": "",
"description": ""
}
},
"artists": "Künstler",
"albums": "Alben",
"songs": "Musik",
"libraryCount": "Bibliotheken",
"audiobooks": "Hörbücher",
"podcasts": "Podcasts",
"listeningTime": "Hördauer",
"activeSessions": "Aktive Sitzungen",
"nowPlaying": "Aktuelle Wiedergabe",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"artists": "",
"albums": "",
"songs": "",
"libraryCount": "",
"audiobooks": "",
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": "Fehler beim Abrufen der Audio-Statistikdaten"
"internalServerError": ""
}
},
"systemResources": {
@@ -3471,7 +3457,7 @@
"option": {
"cpu": "CPU",
"memory": "Speicher",
"gpu": "GPU",
"gpu": "",
"network": "Netzwerk"
}
},
@@ -3488,7 +3474,7 @@
"card": {
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"gpu": "GPU",
"gpu": "",
"network": "Netzwerk",
"up": "OBEN",
"down": "UNTEN"
@@ -3524,93 +3510,93 @@
}
},
"customApi": {
"name": "Benutzerdefinierte API",
"description": "Zeigt Daten von jeder externen API mit benutzerdefinierter Konfiguration an.",
"name": "",
"description": "",
"option": {
"definitionId": {
"label": "Definitions ID"
"label": ""
},
"refreshInterval": {
"label": "Aktualisierungsintervall",
"description": "Wie oft Daten von der API neu geladen werden sollen (in Sekunden)"
"label": "",
"description": ""
}
},
"noDefinition": "Keine Definition ausgewählt",
"definitionNotFound": "Widget-Definition nicht gefunden. Möglicherweise wurde sie gelöscht.",
"noDisplayOutput": "Keine Anzeige-Ausgabe",
"executeConfirm": "Bist du dir sicher, dass du diese Aktion ausführen möchtest?",
"executing": "Wird ausgeführt...",
"executeSuccess": "Aktion erfolgreich ausgeführt",
"executeFailed": "Aktion fehlgeschlagen. Überprüfen Sie die Widget-Konfiguration."
"noDefinition": "",
"definitionNotFound": "",
"noDisplayOutput": "",
"executeConfirm": "",
"executing": "",
"executeSuccess": "",
"executeFailed": ""
},
"beszelSystemTable": {
"name": "Beszel Systeme (Tabelle)",
"description": "Tabellenansicht aller Beszel-überwachten Systeme mit sortierbaren Spalten und Statusindikatoren",
"name": "",
"description": "",
"option": {
"statusFilter": {
"label": "Status Filter"
"label": ""
},
"sortBy": {
"label": "Sortieren nach"
"label": ""
},
"sortDirection": {
"label": "Sortierreihenfolge"
"label": ""
},
"showCpu": {
"label": "CPU anzeigen"
"label": ""
},
"showMemory": {
"label": "Arbeitsspeicher anzeigen"
"label": ""
},
"showDisk": {
"label": "Datenträger anzeigen"
"label": ""
},
"showGpu": {
"label": "GPU anzeigen"
"label": ""
},
"showLoadAvg": {
"label": "Zeige Belastungsdurchschnitt"
"label": ""
},
"showNet": {
"label": "Netzwerk anzeigen"
"label": ""
},
"showTemp": {
"label": "Temperatur anzeigen"
"label": ""
},
"showBattery": {
"label": "Akkustand anzeigen"
"label": ""
},
"showServices": {
"label": "Dienste anzeigen"
"label": ""
},
"showUptime": {
"label": "Laufzeit anzeigen"
"label": ""
},
"showAgent": {
"label": "Agent-Version anzeigen"
"label": ""
}
},
"noRecords": "Keine Systeme gefunden",
"noRecords": "",
"error": {
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen."
"internalServerError": ""
},
"column": {
"system": "System",
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"disk": "Datenträger",
"gpu": "GPU",
"loadAvg": "Systemlast",
"net": "Netz",
"temp": "Temperatur",
"battery": "Batterie",
"services": "Dienste",
"uptime": "Betriebszeit",
"agent": "Agent"
"system": "",
"cpu": "",
"memory": "",
"disk": "",
"gpu": "",
"loadAvg": "",
"net": "",
"temp": "",
"battery": "",
"services": "",
"uptime": "",
"agent": ""
}
},
"beszelSystemGrid": {
"name": "Beszel Systems (Grid)",
"name": "",
"description": "",
"option": {
"statusFilter": {
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "CPU",
"memory": "Arbeitsspeicher",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "System auswählen",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -3740,7 +3720,7 @@
},
"chart": {
"cpu": {
"title": "CPU Auslastung",
"title": "CPU-Auslastung",
"subtitle": "",
"series": "CPU"
},
@@ -4153,11 +4133,6 @@
"title": "Nicht gefunden",
"text": "Konnte die angeforderte Ressource nicht finden"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Startseite",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Fehler beim Abrufen der Docker Container",
"noColumns": ""
"internalServerError": "Fehler beim Abrufen der Docker Container"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "εδώ",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Εναλλαγή σε σκοτεινή λειτουργία",
"switchToLightMode": "Εναλλαγή σε φωτεινή λειτουργία",
"management": "Διαχείριση",
"docker": "",
"preferences": "Οι ρυθμίσεις σας",
"logout": "Αποσύνδεση",
"login": "Σύνδεση",
@@ -2357,9 +2352,6 @@
"name": "Στατιστικά Docker",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Αρχική",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+35 -64
View File
@@ -177,7 +177,7 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Password suggestions",
"suggestionsTitle": "",
"suggestion": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
@@ -687,28 +687,28 @@
"customWidget": {
"page": {
"list": {
"title": "Custom Widgets",
"noResults": "No custom widgets yet. Create your first one!",
"loading": "Loading..."
"title": "",
"noResults": "",
"loading": ""
},
"create": {
"title": "Create Custom Widget",
"subtitle": "Define an API URL, authentication, and how to display the response data."
"title": "",
"subtitle": ""
},
"edit": {
"title": "Edit Custom Widget"
"title": ""
}
},
"action": {
"create": "Create",
"import": "Import",
"save": "Save",
"delete": "Delete Custom Widget",
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"export": "Export",
"addMapping": "Add Mapping",
"addColumn": "Add Column",
"edit": "Edit",
"create": "",
"import": "",
"save": "",
"delete": "",
"deleteConfirm": "",
"export": "",
"addMapping": "",
"addColumn": "",
"edit": "",
"removeMapping": "",
"removeColumn": "",
"addItem": "",
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Switch to dark mode",
"switchToLightMode": "Switch to light mode",
"management": "Management",
"docker": "",
"preferences": "Your preferences",
"logout": "Log-out",
"login": "Log-in",
@@ -2357,9 +2352,6 @@
"name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Enable items sorting"
},
@@ -2413,12 +2405,6 @@
"title": "Instances"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
@@ -5628,71 +5599,71 @@
"label": "",
"children": {
"detail": {
"title": "Select your colour scheme"
"title": ""
}
}
},
"locale": {
"label": "Language",
"label": "",
"children": {
"detail": {
"title": "Select your preferred language"
"title": ""
}
}
},
"defaultSearchEngineId": {
"label": "Default search engine",
"label": "",
"children": {
"detail": {
"title": "Select your default search engine"
"title": ""
}
}
},
"openSearchInNewTab": {
"label": "Open search results in new tab",
"label": "",
"children": {
"detail": {
"title": "Open search results in new tab"
"title": ""
}
}
},
"ddgBangs": {
"label": "Enable DuckDuckGo bangs",
"label": "",
"children": {
"detail": {
"title": "Enable DuckDuckGo bangs"
"title": ""
}
}
},
"firstDayOfWeek": {
"label": "First day of the week",
"label": "",
"children": {
"detail": {
"title": "Select the first day of the week"
"title": ""
}
}
},
"homeBoardId": {
"label": "Home board",
"label": "",
"children": {
"detail": {
"title": "Select your home board"
"title": ""
}
}
},
"mobileHomeBoardId": {
"label": "Mobile home board",
"label": "",
"children": {
"detail": {
"title": "Select your mobile home board"
"title": ""
}
}
},
"pingIconsEnabled": {
"label": "Use icons for pings",
"label": "",
"children": {
"detail": {
"title": "Use icons for pings"
"title": ""
}
}
},
+2 -30
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Monitoring",
"speedtest": "Speed Test",
"analytics": "Analytics",
"vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring"
},
@@ -1468,7 +1467,6 @@
"switchToDarkMode": "Switch to dark mode",
"switchToLightMode": "Switch to light mode",
"management": "Management",
"docker": "Docker",
"preferences": "Your preferences",
"logout": "Logout",
"login": "Login",
@@ -1598,9 +1596,7 @@
},
"menu": {
"label": {
"settings": "Settings",
"options": "Options",
"actions": "Actions"
"settings": "Settings"
}
},
"create": {
@@ -2359,9 +2355,6 @@
"name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": {
"columns": {
"label": "Columns to show"
},
"enableRowSorting": {
"label": "Enable items sorting"
},
@@ -2415,12 +2408,6 @@
"title": "Instances"
}
},
"vpn": {
"name": "VPN",
"description": "Overview of your VPN connections",
"serviceUnavailable": "VPN unavailable",
"option": {}
},
"ups": {
"name": "UPS",
"description": "Monitor the status of your UPS devices through a NUT server",
@@ -3655,9 +3642,6 @@
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
},
"empty": {
"noSystems": "No systems found"
},
"metric": {
"cpu": "CPU",
"memory": "Memory",
@@ -3731,9 +3715,6 @@
}
},
"selectSystem": "Select system",
"empty": {
"noSystems": "No systems found"
},
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
@@ -4155,11 +4136,6 @@
"title": "Not Found",
"text": "Could not find requested resource"
},
"error": {
"title": "This page couldn't load",
"text": "An unexpected error occurred while loading this page. Your navigation is still available — try reloading, or use another section from the sidebar.",
"digest": "Error reference: {digest}"
},
"navbar": {
"items": {
"home": "Home",
@@ -4606,9 +4582,6 @@
},
"umamiActiveVisitors": {
"label": "Umami Active Visitors"
},
"vpn": {
"label": "VPN Status"
}
},
"interval": {
@@ -5101,8 +5074,7 @@
}
},
"error": {
"internalServerError": "Failed to fetch Docker containers",
"noColumns": "Select at least one column in the widget settings"
"internalServerError": "Failed to fetch Docker containers"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "aquí",
"iconPicker": {
"label": "URL del icono",
"header": "Escribe nombre u objetos para filtrar iconos... Homarr buscará a través de iconos {countIcons} por ti.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Escribe nombre u objetos para filtrar iconos... Homarr buscará a través de iconos {countIcons} por ti."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Cambiar a modo oscuro",
"switchToLightMode": "Cambiar a modo claro",
"management": "Administración",
"docker": "",
"preferences": "Tus preferencias",
"logout": "Cerrar sesión",
"login": "Iniciar sesión",
@@ -2357,9 +2352,6 @@
"name": "Estadísticas de Docker",
"description": "Estadísticas de tus contenedores (Este widget sólo puede ser añadido con privilegios de administrador)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Habilitar ordenar elementos"
},
@@ -2413,12 +2405,6 @@
"title": "Instancias"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "No Encontrado",
"text": "No se pudo encontrar el recurso solicitado"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Inicio",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Fallo al obtener los contenedores de Docker",
"noColumns": ""
"internalServerError": "Fallo al obtener los contenedores de Docker"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "Teie eelistused",
"logout": "",
"login": "",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+3 -32
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Valvonta",
"speedtest": "Nopeustestaus",
"analytics": "Analytiikka",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1045,7 +1044,7 @@
"description": "Vastausta ei voitu jäsentää. Tarkista, että URL-osoite osoittaa palvelun perusosoitteeseen."
},
"authorization": {
"title": "Tunnistautumisvirhe",
"title": "Tuonnistautumisvirhe",
"description": "Pyyntöä ei hyväksytty. Varmista, että kirjautumistiedot ovat oikein ja että niille on määritetty riittävät käyttöoikeudet."
},
"statusCode": {
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "Kuvakkeen URL-osoite",
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "Hallinta",
"docker": "",
"preferences": "Asetuksesi",
"logout": "Kirjaudu ulos",
"login": "",
@@ -2357,9 +2352,6 @@
"name": "Docker-tilastot",
"description": "Konttiesi tilastot (tämä widget voidaan lisätä vain ylläpito-oikeuksilla)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Salli kohteiden järjestys"
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Aloitus",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "Umamin aktiiviset käyttäjät"
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "ici",
"iconPicker": {
"label": "URL de l'icône :",
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Activer le mode sombre",
"switchToLightMode": "Activer le mode clair",
"management": "Administration",
"docker": "",
"preferences": "Vos préférences",
"logout": "Se déconnecter",
"login": "Connexion",
@@ -2357,9 +2352,6 @@
"name": "Statistiques de Docker",
"description": "Statistiques de vos conteneurs (Ce widget ne peut être ajouté qu'avec les privilèges d'administrateur)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Activer le tri des éléments"
},
@@ -2413,12 +2405,6 @@
"title": "Instances"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Non trouvé",
"text": "Impossible de trouver la ressource demandée"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Accueil",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Impossible de récupérer les conteneurs Docker",
"noColumns": ""
"internalServerError": "Impossible de récupérer les conteneurs Docker"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "כאן",
"iconPicker": {
"label": "קישור של הסמל",
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} .",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} ."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "עבור למצב כהה",
"switchToLightMode": "עבור למצב בהיר",
"management": "ניהול",
"docker": "",
"preferences": "העדפות שלך",
"logout": "התנתק/י",
"login": "התחבר/י",
@@ -2357,9 +2352,6 @@
"name": "סטטיסטיקות דוקר",
"description": "סטטיסטיקות של המכולות שלך (ניתן להוסיף ווידג'ט זה רק עם הרשאות מנהל)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "אפשר מיון פריטים"
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "לא נמצא",
"text": "לא ניתן לאתר את המשאב המבוקש"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "מסך הבית",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "נכשל בקבלת קונטיינרי דוקר",
"noColumns": ""
"internalServerError": "נכשל בקבלת קונטיינרי דוקר"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "Vaše preferencije",
"logout": "",
"login": "Prijaviti se",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Dom",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "itt",
"iconPicker": {
"label": "Ikonhivatkozás",
"header": "Írjon be nevet vagy objektumokat az ikonok szűréséhez... A Homarr a {countIcons} ikonok között keres.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Írjon be nevet vagy objektumokat az ikonok szűréséhez... A Homarr a {countIcons} ikonok között keres."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Váltás sötét módba",
"switchToLightMode": "Váltás világos módra",
"management": "Kezelés",
"docker": "",
"preferences": "Saját preferenciák",
"logout": "Kijelentkezés",
"login": "Bejelentkezés",
@@ -2357,9 +2352,6 @@
"name": "Docker statisztika",
"description": "A konténereid statisztikái (Ezt a widgetet csak rendszergazdai jogosultsággal lehet hozzáadni)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Az elemek rendezésének engedélyezése"
},
@@ -2413,12 +2405,6 @@
"title": "Példányok"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Nyitólap",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+4 -33
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Monitoraggio",
"speedtest": "Speed test",
"analytics": "Analytics",
"vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring"
},
@@ -1413,10 +1412,7 @@
"here": "qui",
"iconPicker": {
"label": "Icona URL",
"header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te.",
"headerLoading": "Digita per cercare icone…",
"noResults": "Nessuna icona corrisponde a \"{search}\".",
"error": "Impossibile caricare le icone."
"header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Passa alla modalità scura",
"switchToLightMode": "Passa al tema chiaro",
"management": "Gestione",
"docker": "Docker",
"preferences": "Le tue impostazioni",
"logout": "Disconnettiti",
"login": "Accedi",
@@ -2357,9 +2352,6 @@
"name": "Statistiche del docker",
"description": "Statistiche dei tuoi contenitori (questo widget può essere aggiunto solo con privilegi di amministratore)",
"option": {
"columns": {
"label": "Colonne da mostrare"
},
"enableRowSorting": {
"label": "Abilita l'ordinamento degli elementi"
},
@@ -2413,12 +2405,6 @@
"title": "Istanze"
}
},
"vpn": {
"name": "VPN",
"description": "Panoramica delle tue connessioni VPN",
"serviceUnavailable": "VPN non disponibile",
"option": {}
},
"ups": {
"name": "UPS",
"description": "Monitora lo stato dei tuoi dispositivi UPS tramite un server NUT",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
},
"empty": {
"noSystems": "Nessun sistema trovato"
},
"metric": {
"cpu": "CPU",
"memory": "Memoria",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "Seleziona sistema",
"empty": {
"noSystems": "Nessun sistema trovato"
},
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione.",
"liveConnectionFailed": "Connessione Live non riuscita. L'istanza di Beszel potrebbe non supportare lo streaming in tempo reale.",
@@ -4153,11 +4133,6 @@
"title": "Non trovato",
"text": "Impossibile trovare la risorsa richiesta"
},
"error": {
"title": "Questa pagina non può essere caricata",
"text": "Si è verificato un errore inatteso durante il caricamento di questa pagina. La navigazione è ancora disponibile: prova a ricaricare o usa un'altra sezione dalla barra laterale.",
"digest": "Riferimento errore: {digest}"
},
"navbar": {
"items": {
"home": "Home",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "Visitatori Attivi Umami"
},
"vpn": {
"label": "Stato VPN"
}
},
"interval": {
@@ -4971,8 +4943,8 @@
"search": "Cerca {count} container",
"selected": "{selectCount} di {totalCount} containers selezionati",
"footer": "Totale container {count}",
"totalCpu": "CPU Totale: {cpu}%",
"totalMemory": "Mem Totale: {memory}"
"totalCpu": "",
"totalMemory": ""
},
"field": {
"name": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Recupero dei container Docker non riuscito",
"noColumns": "Seleziona almeno una colonna nelle impostazioni dei widget"
"internalServerError": "Recupero dei container Docker non riuscito"
}
},
"kubernetes": {
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more