Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83738bfebf | ||
|
|
5089a2ae6b | ||
|
|
b83c8b6546 | ||
|
|
0e4189f693 | ||
|
|
1cf010e9fb | ||
|
|
7e58858353 | ||
|
|
9ed55d57dd | ||
|
|
8cac0b11c1 | ||
|
|
5f011e8456 | ||
|
|
9e1b529288 | ||
|
|
4ad115dd2d | ||
|
|
27209a5dde | ||
|
|
ed33748a49 | ||
|
|
7a21e2a9c3 | ||
|
|
f66469d797 | ||
|
|
c4a0c818d0 | ||
|
|
ef016061ae | ||
|
|
3492700e6b | ||
|
|
590d316671 | ||
|
|
6e9d20e651 | ||
|
|
ed829fddfa | ||
|
|
ed8b8a40eb | ||
|
|
7bc3bbb8c0 | ||
|
|
74943ef9bc | ||
|
|
5dd7f839ee | ||
|
|
d8c6c0f978 | ||
|
|
75b2d1bd1b | ||
|
|
7c12a9ce48 | ||
|
|
9033acc66c | ||
|
|
a42dc34d34 | ||
|
|
db3d58821e | ||
|
|
076526293d | ||
|
|
2443f1cd3f | ||
|
|
3864727502 | ||
|
|
f3608b26a0 | ||
|
|
73bed51df8 | ||
|
|
ab9aca8b60 | ||
|
|
dcde2819eb | ||
|
|
abd16354bc | ||
|
|
6ac352c520 | ||
|
|
19f9769d9e | ||
|
|
f0b2486c8a | ||
|
|
2443b89b46 | ||
|
|
915c8a3501 | ||
|
|
a2bb53a8b8 | ||
|
|
f56a05bd8e | ||
|
|
6051b17401 | ||
|
|
29eed08408 | ||
|
|
bd74fe4a61 | ||
|
|
b88e8861e1 | ||
|
|
39af3ec7e6 | ||
|
|
7f50be3dbb | ||
|
|
b6b7bea1dd | ||
|
|
9bde1dfbd6 | ||
|
|
c2712ed7f1 | ||
|
|
16be7ece33 | ||
|
|
b0a46186d1 | ||
|
|
d0385a4480 | ||
|
|
3d6a3594a3 | ||
|
|
641ee18278 | ||
|
|
2e538bf40d | ||
|
|
1c0a94a6dc | ||
|
|
3a3dc449de | ||
|
|
d6b156b8ee | ||
|
|
fc4e31fb42 | ||
|
|
b82b22431b | ||
|
|
510cba295a | ||
|
|
56d838dafc | ||
|
|
eae9f0438c | ||
|
|
b81f729bfd | ||
|
|
86b83c5425 | ||
|
|
bbc4589c9e | ||
|
|
ea9dd716f0 | ||
|
|
4383da38ef | ||
|
|
58cb65cb3a | ||
|
|
89380f81dd | ||
|
|
e01571b221 | ||
|
|
49c266374d | ||
|
|
577950d20e | ||
|
|
82bf71b073 | ||
|
|
e26883acfa | ||
|
|
8ee747cde8 | ||
|
|
faa7e55b1b | ||
|
|
ce021a89ac | ||
|
|
55fb227305 | ||
|
|
39e424563a | ||
|
|
e29c79a902 | ||
|
|
a19c9d88ca | ||
|
|
1fb97ee9c6 | ||
|
|
f69e545d64 | ||
|
|
52df56b19d | ||
|
|
0f66bfd6c1 | ||
|
|
842c5a0b4c | ||
|
|
ce9134dee2 | ||
|
|
9ce9be6b7e | ||
|
|
7ec22411a6 | ||
|
|
d3ae06e515 | ||
|
|
d09a4f8468 | ||
|
|
e1a245ff2d | ||
|
|
bf4396917e | ||
|
|
d059023b51 | ||
|
|
d9cbd5427d | ||
|
|
c56d9d6894 | ||
|
|
3ea49f0391 | ||
|
|
e2f8f5f081 | ||
|
|
f57037b568 | ||
|
|
84803aeefb | ||
|
|
5f47298f3d | ||
|
|
de858011fc | ||
|
|
12f8e2b83a | ||
|
|
78b6e520ac | ||
|
|
bcaa93f90b | ||
|
|
15e6612697 | ||
|
|
eef06dd5a3 | ||
|
|
84e044f427 | ||
|
|
609ab9976a | ||
|
|
28f570aa79 | ||
|
|
5b4f03da35 | ||
|
|
6960ac15e9 | ||
|
|
f1ee76c99c | ||
|
|
9a9ee5a9d7 |
No files matched your search
@@ -33,6 +33,9 @@ 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
|
||||
- 1.65.0
|
||||
- 1.64.0
|
||||
|
||||
@@ -163,3 +163,65 @@ 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.16.0
|
||||
node-version: 24.18.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
|
||||
@@ -3,6 +3,7 @@ name: "[Deployment] Automatic Weekly Release"
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||
timezone: Europe/Zurich
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
send-notifications:
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[tools]
|
||||
node = "24.16.0"
|
||||
node = "24.18.0"
|
||||
@@ -1,3 +1,49 @@
|
||||
## [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
|
||||
|
||||
* **analytics:** migrate from Umami to PostHog server-side analytics ([#5983](https://github.com/homarr-labs/homarr/issues/5983)) ([d059023](https://github.com/homarr-labs/homarr/commit/d059023b51a323df027c1cf033454440bd0d7e57))
|
||||
* Calculate and display total CPU and memory usage ([#5911](https://github.com/homarr-labs/homarr/issues/5911)) ([d9cbd54](https://github.com/homarr-labs/homarr/commit/d9cbd5427d0e5be3e5dbecca8d6069dc8074d810))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** isolate session cookies from other Auth.js apps on same host ([#5996](https://github.com/homarr-labs/homarr/issues/5996)) ([e1a245f](https://github.com/homarr-labs/homarr/commit/e1a245ff2d8520d08957c4a7dbe01d039607a973))
|
||||
* **deps:** update tailwindcss monorepo to ^4.3.1 ([#5988](https://github.com/homarr-labs/homarr/issues/5988)) ([78b6e52](https://github.com/homarr-labs/homarr/commit/78b6e520ac095129398d3da8f2441df408e93e9f))
|
||||
* **downloads:** sort stalled torrents last when sorting by finish time ([#6011](https://github.com/homarr-labs/homarr/issues/6011)) ([c56d9d6](https://github.com/homarr-labs/homarr/commit/c56d9d6894c717b5c70ab75d6a100d4d1071c2d3)), closes [#5416](https://github.com/homarr-labs/homarr/issues/5416)
|
||||
|
||||
## [1.65.0](https://github.com/homarr-labs/homarr/compare/v1.64.0...v1.65.0) (2026-06-12)
|
||||
|
||||
### Features
|
||||
|
||||
+187
-1
@@ -1,7 +1,193 @@
|
||||
# ── CODEOWNERS ───────────────────────────────────────
|
||||
* @homarr-labs/maintainers
|
||||
|
||||
# Exempt Renovate‑managed files (no owners)
|
||||
# Specific features / integrations
|
||||
# These owners come from the PR that introduced each integration implementation.
|
||||
# Specific rules override the global fallback because CODEOWNERS uses the last
|
||||
# matching pattern; do not include @homarr-labs/maintainers here unless they
|
||||
# should also be requested for that integration.
|
||||
|
||||
# AdGuard Home - introduced in https://github.com/homarr-labs/homarr/pull/929 by @hillaliy
|
||||
apps/docs/docs/integrations/adguard-home @hillaliy
|
||||
packages/integrations/src/adguard-home @hillaliy
|
||||
|
||||
# Anchor - introduced in https://github.com/homarr-labs/homarr/pull/5040 by @ohmzi
|
||||
apps/docs/docs/integrations/anchor @ohmzi
|
||||
packages/integrations/src/anchor @ohmzi
|
||||
|
||||
# ArchiveTeam Warrior - introduced in https://github.com/homarr-labs/homarr/pull/5784 by @darbyjack
|
||||
apps/docs/docs/integrations/archive-team-warrior @darbyjack
|
||||
packages/integrations/src/archive-team-warrior @darbyjack
|
||||
|
||||
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
|
||||
apps/docs/docs/integrations/audiobookshelf @ajnart
|
||||
packages/integrations/src/audiobookshelf @ajnart
|
||||
|
||||
# Beszel - introduced in https://github.com/homarr-labs/homarr/pull/5900 by @ajnart
|
||||
apps/docs/docs/integrations/beszel @ajnart
|
||||
packages/integrations/src/beszel @ajnart
|
||||
|
||||
# Codeberg - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
|
||||
apps/docs/docs/integrations/codeberg @Aandree5
|
||||
packages/integrations/src/codeberg @Aandree5
|
||||
|
||||
# Coolify - introduced in https://github.com/homarr-labs/homarr/pull/4789 by @Rhiz3K
|
||||
apps/docs/docs/integrations/coolify @Rhiz3K
|
||||
packages/integrations/src/coolify @Rhiz3K
|
||||
|
||||
# Dash. - introduced in https://github.com/homarr-labs/homarr/pull/1541 by @manuel-rw
|
||||
apps/docs/docs/integrations/dash-dot @manuel-rw
|
||||
packages/integrations/src/dashdot @manuel-rw
|
||||
|
||||
# Docker Hub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
|
||||
apps/docs/docs/integrations/docker-hub @Aandree5
|
||||
packages/integrations/src/docker-hub @Aandree5
|
||||
|
||||
# Emby - introduced in https://github.com/homarr-labs/homarr/pull/2466 by @Meierschlumpf
|
||||
apps/docs/docs/integrations/emby @Meierschlumpf
|
||||
packages/integrations/src/emby @Meierschlumpf
|
||||
|
||||
# GitHub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
|
||||
apps/docs/docs/integrations/github @Aandree5
|
||||
packages/integrations/src/github @Aandree5
|
||||
|
||||
# GitHub Container Registry - introduced in https://github.com/homarr-labs/homarr/pull/3727 by @Aandree5
|
||||
apps/docs/docs/integrations/github-containerregistry @Aandree5
|
||||
packages/integrations/src/github-container-registry @Aandree5
|
||||
|
||||
# GitLab - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
|
||||
apps/docs/docs/integrations/gitlab @Aandree5
|
||||
packages/integrations/src/gitlab @Aandree5
|
||||
|
||||
# Glances - introduced in https://github.com/homarr-labs/homarr/pull/5102 by @manuel-rw
|
||||
apps/docs/docs/integrations/glances @manuel-rw
|
||||
packages/integrations/src/glances @manuel-rw
|
||||
|
||||
# Gluetun - introduced in https://github.com/homarr-labs/homarr/pull/5812 by @Lok3sh-kumar
|
||||
apps/docs/docs/integrations/gluetun @Lok3sh-kumar
|
||||
packages/integrations/src/gluetun @Lok3sh-kumar
|
||||
|
||||
# Gotify - introduced in https://github.com/homarr-labs/homarr/pull/5847 by @Nerdy-Technician
|
||||
apps/docs/docs/integrations/gotify @Nerdy-Technician
|
||||
packages/integrations/src/gotify @Nerdy-Technician
|
||||
|
||||
# Home Assistant - introduced in https://github.com/homarr-labs/homarr/pull/578 by @manuel-rw
|
||||
apps/docs/docs/integrations/home-assistant @manuel-rw
|
||||
packages/integrations/src/homeassistant @manuel-rw
|
||||
|
||||
# ICal - introduced in https://github.com/homarr-labs/homarr/pull/3980 by @NicolasNewman
|
||||
apps/docs/docs/integrations/ical @NicolasNewman
|
||||
packages/integrations/src/ical @NicolasNewman
|
||||
|
||||
# Immich - introduced in https://github.com/homarr-labs/homarr/pull/5165 by @manuel-rw
|
||||
apps/docs/docs/integrations/immich @manuel-rw
|
||||
packages/integrations/src/immich @manuel-rw
|
||||
|
||||
# Jellyfin - introduced in https://github.com/homarr-labs/homarr/pull/672 by @manuel-rw
|
||||
apps/docs/docs/integrations/jellyfin @manuel-rw
|
||||
packages/integrations/src/jellyfin @manuel-rw
|
||||
|
||||
# Jellyseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
|
||||
apps/docs/docs/integrations/jellyseerr @manuel-rw
|
||||
packages/integrations/src/jellyseerr @manuel-rw
|
||||
|
||||
# LinuxServer.io - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
|
||||
apps/docs/docs/integrations/linux-server-io @Aandree5
|
||||
packages/integrations/src/linuxserverio @Aandree5
|
||||
|
||||
# Navidrome - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
|
||||
apps/docs/docs/integrations/navidrome @ajnart
|
||||
packages/integrations/src/navidrome @ajnart
|
||||
|
||||
# Nextcloud - introduced in https://github.com/homarr-labs/homarr/pull/2501 by @manuel-rw
|
||||
apps/docs/docs/integrations/nextcloud @manuel-rw
|
||||
packages/integrations/src/nextcloud @manuel-rw
|
||||
|
||||
# npm - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
|
||||
apps/docs/docs/integrations/npm @Aandree5
|
||||
packages/integrations/src/npm @Aandree5
|
||||
|
||||
# ntfy - introduced in https://github.com/homarr-labs/homarr/pull/2900 by @itzTheMeow
|
||||
apps/docs/docs/integrations/ntfy @ajnart @manuel-rw
|
||||
packages/integrations/src/ntfy @ajnart @manuel-rw
|
||||
|
||||
# OpenMediaVault - introduced in https://github.com/homarr-labs/homarr/pull/1142 by @hillaliy
|
||||
apps/docs/docs/integrations/open-media-vault @hillaliy
|
||||
packages/integrations/src/openmediavault @hillaliy
|
||||
|
||||
# OPNsense - introduced in https://github.com/homarr-labs/homarr/pull/3424 by @Oupsman
|
||||
apps/docs/docs/integrations/opnsense @Oupsman
|
||||
packages/integrations/src/opnsense @Oupsman
|
||||
|
||||
# Overseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
|
||||
apps/docs/docs/integrations/overseerr @manuel-rw
|
||||
packages/integrations/src/overseerr @manuel-rw
|
||||
|
||||
# Paperless-ngx - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
|
||||
apps/docs/docs/integrations/paperless-ngx @ajnart
|
||||
packages/integrations/src/paperless-ngx @ajnart
|
||||
|
||||
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
|
||||
apps/docs/docs/integrations/peanut @afonsojramos
|
||||
packages/integrations/src/peanut @afonsojramos
|
||||
|
||||
# Pi-hole - introduced in https://github.com/homarr-labs/homarr/pull/521 by @Meierschlumpf
|
||||
apps/docs/docs/integrations/pi-hole @Meierschlumpf
|
||||
packages/integrations/src/pi-hole @Meierschlumpf
|
||||
|
||||
# Plex - introduced in https://github.com/homarr-labs/homarr/pull/1342 by @hillaliy
|
||||
apps/docs/docs/integrations/plex @hillaliy
|
||||
packages/integrations/src/plex @hillaliy
|
||||
|
||||
# Prowlarr - introduced in https://github.com/homarr-labs/homarr/pull/965 by @hillaliy
|
||||
apps/docs/docs/integrations/prowlarr @hillaliy
|
||||
packages/integrations/src/prowlarr @hillaliy
|
||||
|
||||
# Proxmox - introduced in https://github.com/homarr-labs/homarr/pull/1969 by @Meierschlumpf
|
||||
apps/docs/docs/integrations/proxmox @Meierschlumpf
|
||||
packages/integrations/src/proxmox @Meierschlumpf
|
||||
|
||||
# Quay - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
|
||||
apps/docs/docs/integrations/quay @Aandree5
|
||||
packages/integrations/src/quay @Aandree5
|
||||
|
||||
# search.ch - introduced in https://github.com/homarr-labs/homarr/pull/5180 by @Meierschlumpf
|
||||
apps/docs/docs/integrations/search-ch @Meierschlumpf
|
||||
packages/integrations/src/search-ch @Meierschlumpf
|
||||
|
||||
# Speedtest Tracker - introduced in https://github.com/homarr-labs/homarr/pull/5363 by @kylegrieder
|
||||
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
|
||||
packages/integrations/src/speedtest-tracker @kylegrieder
|
||||
|
||||
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
|
||||
apps/docs/docs/integrations/technitium-dns @lopsch
|
||||
packages/integrations/src/technitium @lopsch
|
||||
|
||||
# Tracearr - introduced in https://github.com/homarr-labs/homarr/pull/5060 by @StrandedTurtle
|
||||
apps/docs/docs/integrations/tracearr @StrandedTurtle
|
||||
packages/integrations/src/tracearr @StrandedTurtle
|
||||
|
||||
# TrueNAS - introduced in https://github.com/homarr-labs/homarr/pull/3745 by @manuel-rw
|
||||
apps/docs/docs/integrations/truenas @manuel-rw @afonsojramos
|
||||
packages/integrations/src/truenas @manuel-rw @afonsojramos
|
||||
|
||||
# Umami - introduced in https://github.com/homarr-labs/homarr/pull/5362 by @febrile42
|
||||
apps/docs/docs/integrations/umami @febrile42
|
||||
packages/integrations/src/umami @febrile42
|
||||
|
||||
# UniFi Controller - introduced in https://github.com/homarr-labs/homarr/pull/2236 by @pitschi
|
||||
apps/docs/docs/integrations/unifi-controller @pitschi
|
||||
packages/integrations/src/unifi-controller @pitschi
|
||||
|
||||
# Unraid - introduced in https://github.com/homarr-labs/homarr/pull/4439 by @manuel-rw
|
||||
apps/docs/docs/integrations/unraid @manuel-rw
|
||||
packages/integrations/src/unraid @manuel-rw
|
||||
|
||||
# Uptime Kuma - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
|
||||
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||
packages/integrations/src/uptime-kuma @ajnart
|
||||
|
||||
# Exempt Renovate-managed files (no owners)
|
||||
package.json
|
||||
**/package.json
|
||||
package-lock.json
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:24.16.0-alpine AS base
|
||||
FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -23,6 +23,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
| AUTH_PROVIDERS | Select Which provider to use between *credentials*, *ldap* and *oidc*. <br /> Multiple providers can be enabled with by separating them with `,`, (ex. `AUTH_PROVIDERS=credentials,oidc`, it is highly recommended to just enable one provider). | credentials |
|
||||
| AUTH_LOGOUT_REDIRECT_URL | URL to redirect to after clicking logging out. | --- |
|
||||
| AUTH_SESSION_EXPIRY_TIME | Time for the session to time out. Can be set as pure number, which will automatically be used in seconds, or followed by s, m, h or d for seconds, minutes, hours or days. (ex: "30m") | "30d" |
|
||||
| AUTH_COOKIE_PREFIX | Prefix used for all authentication cookies (session, csrf, callback, pkce, state, nonce). Change this if you run another Auth.js/NextAuth app on the same hostname as Homarr (browsers scope cookies by host, not by port) to avoid cookie name collisions. Only letters, numbers, hyphens and underscores are allowed. | `homarr` |
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="credentials" label="Credentials Provider" default>
|
||||
|
||||
@@ -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">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -383,6 +383,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
|
||||
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
|
||||
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
|
||||
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
|
||||
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
|
||||
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
|
||||
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "ArchiveTeam Warrior"
|
||||
description: "ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { archiveTeamWarriorIntegration } from ".";
|
||||
|
||||
<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: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const archiveTeamWarriorIntegration: IntegrationDefinition = {
|
||||
name: "ArchiveTeam Warrior",
|
||||
description:
|
||||
"ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
path: "../../integrations/archiveteam-warrior",
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: 'FileFlows'
|
||||
description: "FileFlows is a distributed media processing system that allows you to encode, compress and process your media files across multiple nodes."
|
||||
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 { IconTransform } from "@tabler/icons-react";
|
||||
import { fileFlowsIntegration } from '.';
|
||||
import { mediaTranscodingWidget } from '@site/docs/widgets/media-transcoding';
|
||||
|
||||
|
||||
<IntegrationHeader
|
||||
integration={fileFlowsIntegration}
|
||||
categories={['Media Transcoding']}
|
||||
/>
|
||||
|
||||
### Widgets & Capabilities
|
||||
<IntegrationCapabilites
|
||||
items={[{ widget: mediaTranscodingWidget }]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: ['apiKey'],
|
||||
steps: []
|
||||
}, {
|
||||
credentials: [],
|
||||
steps: []
|
||||
}]} />
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
export const fileFlowsIntegration: IntegrationDefinition = {
|
||||
name: "FileFlows",
|
||||
description:
|
||||
"FileFlows is a distributed media processing system that allows you to encode, compress and process your media files across multiple nodes.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/fileflows.png",
|
||||
path: "../../integrations/fileflows",
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: 'Gluetun'
|
||||
description: 'Connect Gluetun to Homarr as a VPN integration.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from '@site/src/components/integrations/header';
|
||||
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
|
||||
import { AddingIntegration } from '@site/src/components/integrations/adding';
|
||||
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
||||
import { gluetunIntegration } from '.';
|
||||
import { vpnWidget } from '@site/docs/widgets/vpn';
|
||||
|
||||
<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: [],
|
||||
},,]} />
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const gluetunIntegration: IntegrationDefinition = {
|
||||
name: "Gluetun",
|
||||
description: "VPN control server integration for Homarr's VPN widget.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg",
|
||||
path: "../../integrations/gluetun",
|
||||
};
|
||||
@@ -63,7 +63,9 @@ import withoutIntegrations from './img/without-integrations.png';
|
||||
|
||||
### Interactions
|
||||
|
||||
- View calendar events by clicking on the marked dates.
|
||||
- Hover over a marked date to preview its events in a popover.
|
||||
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
|
||||
- Click the date again or click outside to close the pinned popover.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: 'VPN'
|
||||
description: 'Monitor the status of your VPN integrations.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { vpnWidget } from '.';
|
||||
import { gluetunIntegration } from '@site/docs/integrations/gluetun';
|
||||
|
||||
<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 />
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconShieldLock } from "@tabler/icons-react";
|
||||
|
||||
export const vpnWidget: WidgetDefinition = {
|
||||
icon: IconShieldLock,
|
||||
name: "VPN",
|
||||
description: "Monitor the connection status, public IP, and provider details of your VPN integrations.",
|
||||
path: "../../widgets/vpn",
|
||||
};
|
||||
@@ -32,7 +32,7 @@
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
@@ -44,7 +44,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^12.2.0",
|
||||
"tailwindcss": "^4.3.0"
|
||||
"tailwindcss": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"mcp-handler": "catalog:",
|
||||
"next": "catalog:",
|
||||
"postcss-preset-mantine": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"prismjs": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
@@ -139,11 +139,11 @@ export default async function Layout(props: {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<Analytics />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -41,11 +40,10 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { PackageJsonDependencies } from "~/versions/package-reader";
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
|
||||
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
|
||||
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
|
||||
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
import crowdinContributors from "@static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -56,32 +54,10 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
const getHostAsync = async () => {
|
||||
if (process.env.HOSTNAME) {
|
||||
return `${process.env.HOSTNAME}:3000`;
|
||||
}
|
||||
|
||||
return (await headers()).get("host");
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const baseServerUrl = `http://${await getHostAsync()}`;
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const version = getPackageVersion();
|
||||
const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
|
||||
res.json(),
|
||||
)) as PackageJsonDependencies;
|
||||
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||
res.json(),
|
||||
)) as typeof githubContributorsJson;
|
||||
|
||||
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
|
||||
res.json(),
|
||||
)) as typeof crowdinContributorsJson;
|
||||
|
||||
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
|
||||
(res) => res.json(),
|
||||
)) as typeof openCollectiveContributorsJson;
|
||||
const dependencies = await getDependenciesAsync();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -185,7 +161,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:"))
|
||||
.sort(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.map(([name, version]) => (
|
||||
<UnstyledButton
|
||||
key={name}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+2
@@ -131,6 +131,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
||||
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
||||
error.data.reason === "hostnameMismatch" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||
@@ -140,6 +141,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
||||
) : null}
|
||||
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
|
||||
@@ -17,9 +17,10 @@ interface AnalyticsSettingsProps {
|
||||
|
||||
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.analytics");
|
||||
const { instanceId: _, ...formDefaults } = initialData;
|
||||
const form = useForm({
|
||||
initialValues: initialData,
|
||||
onValuesChange: (updatedValues, _) => {
|
||||
initialValues: formDefaults,
|
||||
onValuesChange: (updatedValues, _prev) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,16 +149,20 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
},
|
||||
];
|
||||
|
||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||
interface DockerTableProps {
|
||||
initialData?: RouterOutputs["docker"]["getContainers"];
|
||||
}
|
||||
|
||||
export function DockerTable({ initialData }: DockerTableProps) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData: { containers, timestamp },
|
||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
@@ -176,8 +180,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
},
|
||||
});
|
||||
|
||||
const containers = data?.containers ?? [];
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: data.containers,
|
||||
data: containers,
|
||||
state: { isLoading: isFetching },
|
||||
enableDensityToggle: false,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
@@ -190,7 +197,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
enableBottomToolbar: false,
|
||||
positionGlobalFilter: "right",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
|
||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||
style: { minWidth: 300 },
|
||||
autoFocus: true,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable containers={containers} timestamp={timestamp} />
|
||||
<DockerTable initialData={{ containers, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,7 +211,17 @@ describe("SQLite backup", () => {
|
||||
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
||||
expect(decryptedAfter).toBe(plaintext);
|
||||
|
||||
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
||||
// The old key must no longer recover the plaintext. With AES-256-CBC this
|
||||
// usually fails PKCS7 padding validation (throws), but ~1/256 of the time the
|
||||
// wrong key yields valid padding and returns garbage instead of throwing, so
|
||||
// assert on the actual property (plaintext is unrecoverable) rather than throwing.
|
||||
let decryptedWithOldKey: string | null = null;
|
||||
try {
|
||||
decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey);
|
||||
} catch {
|
||||
// Expected for most wrong keys: padding validation fails.
|
||||
}
|
||||
expect(decryptedWithOldKey).not.toBe(plaintext);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
@@ -142,7 +142,8 @@ export const useBackupAnalysis = () => {
|
||||
phase: "error",
|
||||
});
|
||||
throw new Error(
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
||||
{ cause: migrationErr },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -16,6 +18,7 @@ import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
@@ -30,30 +33,33 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -83,6 +89,7 @@ interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
|
||||
@@ -41,7 +41,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
|
||||
return itemA.name.localeCompare(itemB.name);
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -57,6 +57,7 @@ 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,16 +1,24 @@
|
||||
import Script from "next/script";
|
||||
"use client";
|
||||
|
||||
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Analytics = async () => {
|
||||
// For static pages it will not find any analytics data so we do not include the script on them
|
||||
const analytics = await getServerSettingByKeyAsync(db, "analytics").catch(() => null);
|
||||
const POSTHOG_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
|
||||
const POSTHOG_HOST = "https://hog.homarr.dev";
|
||||
|
||||
if (analytics?.enableGeneral) {
|
||||
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
|
||||
}
|
||||
export const Analytics = ({ enabled }: { enabled: boolean }) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
return <></>;
|
||||
void import("posthog-js").then(({ default: posthog }) => {
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
autocapture: false,
|
||||
persistence: "memory",
|
||||
});
|
||||
});
|
||||
}, [enabled]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createModal } from "@homarr/modals";
|
||||
|
||||
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
|
||||
|
||||
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
|
||||
defaultTitle: (t) => t("docker.title"),
|
||||
size: 1400,
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
|
||||
export const UserButton = async () => {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||
return (
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconCheck,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||
|
||||
interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
isDockerEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const { openModal } = useModalAction(LogoutModal);
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
|
||||
const handleSignout = useCallback(async () => {
|
||||
await signOut({
|
||||
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
<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 />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"@static-data/*": ["../../static-data/*"],
|
||||
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.16.0-trixie AS base
|
||||
FROM node:24.18.0-trixie AS base
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0"
|
||||
"packageManager": "pnpm@11.9.0"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.65.0",
|
||||
"version": "1.68.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -61,10 +61,10 @@
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0",
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@umami/node": "catalog:",
|
||||
"superjson": "catalog:"
|
||||
"posthog-node": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
export const POSTHOG_API_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
|
||||
export const POSTHOG_HOST = "https://hog.homarr.dev";
|
||||
|
||||
let instance: PostHog | undefined;
|
||||
|
||||
export const getPostHogClient = (): PostHog => {
|
||||
instance ??= new PostHog(POSTHOG_API_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 10,
|
||||
flushInterval: 30_000,
|
||||
});
|
||||
return instance;
|
||||
};
|
||||
|
||||
export const trackEvent = (instanceId: string, event: string, properties?: Record<string, unknown>) => {
|
||||
getPostHogClient().capture({
|
||||
distinctId: instanceId,
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
|
||||
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./constants";
|
||||
export * from "./send-server-analytics";
|
||||
export { trackEvent } from "./client";
|
||||
export { sendServerAnalyticsAsync } from "./send-server-analytics";
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { UmamiEventData } from "@umami/node";
|
||||
import { Umami } from "@umami/node";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import { createId, Stopwatch } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { count, db } from "@homarr/db";
|
||||
import { isMysql, isPostgresql } from "@homarr/db/collection";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
accounts,
|
||||
apiKeys,
|
||||
apps,
|
||||
boards,
|
||||
cronJobConfigurations,
|
||||
customWidgetDefinitions,
|
||||
groups,
|
||||
iconRepositories,
|
||||
icons,
|
||||
@@ -24,86 +23,160 @@ import {
|
||||
searchEngines,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
sessions,
|
||||
trustedCertificateHostnames,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { env as dockerEnv } from "@homarr/docker/env";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
||||
import { getPostHogClient } from "./client";
|
||||
|
||||
const logger = createLogger({ module: "analytics" });
|
||||
|
||||
const getDatabaseType = (): "mysql" | "postgresql" | "sqlite" => {
|
||||
if (isMysql()) return "mysql";
|
||||
if (isPostgresql()) return "postgresql";
|
||||
return "sqlite";
|
||||
type AnalyticsResult = "sent" | "disabled" | "failed";
|
||||
|
||||
// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
|
||||
// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL.
|
||||
const getOrCreateInstanceId = async (
|
||||
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
|
||||
): Promise<string> => {
|
||||
if (analyticsSettings.instanceId) return analyticsSettings.instanceId;
|
||||
|
||||
const instanceId = createId();
|
||||
await updateServerSettingByKeyAsync(db, "analytics", { ...analyticsSettings, instanceId });
|
||||
|
||||
const verified = await getServerSettingByKeyAsync(db, "analytics");
|
||||
return verified.instanceId ?? instanceId;
|
||||
};
|
||||
|
||||
const getEnabledAuthProviders = (): string[] => {
|
||||
const providers: string[] = [];
|
||||
if (isProviderEnabled("credentials")) providers.push("credentials");
|
||||
if (isProviderEnabled("oidc")) providers.push("oidc");
|
||||
if (isProviderEnabled("ldap")) providers.push("ldap");
|
||||
return providers;
|
||||
};
|
||||
const sumGroupedCounts = (rows: { count: number }[]): number => rows.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
export const sendServerAnalyticsAsync = async () => {
|
||||
export const sendServerAnalyticsAsync = async (): Promise<AnalyticsResult> => {
|
||||
const stopWatch = new Stopwatch();
|
||||
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticsSettings.enableGeneral) {
|
||||
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
||||
return;
|
||||
return "disabled";
|
||||
}
|
||||
|
||||
const umamiInstance = new Umami();
|
||||
umamiInstance.init({
|
||||
hostUrl: UMAMI_HOST_URL,
|
||||
websiteId: UMAMI_WEBSITE_ID,
|
||||
});
|
||||
const client = getPostHogClient();
|
||||
|
||||
const enabledAuthProviders = getEnabledAuthProviders();
|
||||
try {
|
||||
const instanceId = await getOrCreateInstanceId(analyticsSettings);
|
||||
|
||||
const analyticsData: UmamiEventData = {
|
||||
homarrVersion: packageJson.version,
|
||||
databaseType: getDatabaseType(),
|
||||
dockerEnabled: dockerEnv.ENABLE_DOCKER ? 1 : 0,
|
||||
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES ? 1 : 0,
|
||||
authCredentials: enabledAuthProviders.includes("credentials") ? 1 : 0,
|
||||
authOidc: enabledAuthProviders.includes("oidc") ? 1 : 0,
|
||||
authLdap: enabledAuthProviders.includes("ldap") ? 1 : 0,
|
||||
countUsers: await db.$count(users),
|
||||
countBoards: await db.$count(boards),
|
||||
countGroups: await db.$count(groups),
|
||||
countApps: await db.$count(apps),
|
||||
countWidgets: await db.$count(items),
|
||||
countSections: await db.$count(sections),
|
||||
countIntegrations: await db.$count(integrations),
|
||||
countSearchEngines: await db.$count(searchEngines),
|
||||
countIconRepositories: await db.$count(iconRepositories),
|
||||
countIcons: await db.$count(icons),
|
||||
countApiKeys: await db.$count(apiKeys),
|
||||
countInvites: await db.$count(invites),
|
||||
countMedias: await db.$count(medias),
|
||||
countLayouts: await db.$count(layouts),
|
||||
countItemLayouts: await db.$count(itemLayouts),
|
||||
countSectionLayouts: await db.$count(sectionLayouts),
|
||||
countCronJobConfigs: await db.$count(cronJobConfigurations),
|
||||
};
|
||||
const [
|
||||
cultureSettings,
|
||||
countBoards,
|
||||
countGroups,
|
||||
countApps,
|
||||
countSections,
|
||||
countSearchEngines,
|
||||
countIconRepositories,
|
||||
countIcons,
|
||||
countLayouts,
|
||||
countItemLayouts,
|
||||
countSectionLayouts,
|
||||
countCronJobConfigs,
|
||||
countCustomWidgets,
|
||||
countTrustedCertificates,
|
||||
countUsers,
|
||||
countApiKeys,
|
||||
countInvites,
|
||||
countMedias,
|
||||
countSessions,
|
||||
countAccounts,
|
||||
integrationKinds,
|
||||
widgetKinds,
|
||||
] = await Promise.all([
|
||||
getServerSettingByKeyAsync(db, "culture"),
|
||||
db.$count(boards),
|
||||
db.$count(groups),
|
||||
db.$count(apps),
|
||||
db.$count(sections),
|
||||
db.$count(searchEngines),
|
||||
db.$count(iconRepositories),
|
||||
db.$count(icons),
|
||||
db.$count(layouts),
|
||||
db.$count(itemLayouts),
|
||||
db.$count(sectionLayouts),
|
||||
db.$count(cronJobConfigurations),
|
||||
db.$count(customWidgetDefinitions),
|
||||
db.$count(trustedCertificateHostnames),
|
||||
db.$count(users),
|
||||
db.$count(apiKeys),
|
||||
db.$count(invites),
|
||||
db.$count(medias),
|
||||
db.$count(sessions),
|
||||
db.$count(accounts),
|
||||
db
|
||||
.select({ kind: integrations.kind, count: count(integrations.id) })
|
||||
.from(integrations)
|
||||
.groupBy(integrations.kind),
|
||||
db
|
||||
.select({ kind: items.kind, count: count(items.id) })
|
||||
.from(items)
|
||||
.groupBy(items.kind),
|
||||
]);
|
||||
|
||||
const integrationKinds = await db
|
||||
.select({ kind: integrations.kind, count: count(integrations.id) })
|
||||
.from(integrations)
|
||||
.groupBy(integrations.kind);
|
||||
const enabledAuthProviders = (["credentials", "oidc", "ldap"] as const).filter(isProviderEnabled);
|
||||
|
||||
integrationKinds.forEach((integrationKind) => {
|
||||
analyticsData[`integration_${integrationKind.kind}`] = integrationKind.count;
|
||||
});
|
||||
const properties: Record<string, unknown> = {
|
||||
homarrVersion: packageJson.version,
|
||||
databaseType: isMysql() ? "mysql" : isPostgresql() ? "postgresql" : "sqlite",
|
||||
dockerEnabled: Boolean(dockerEnv.ENABLE_DOCKER),
|
||||
kubernetesEnabled: Boolean(dockerEnv.ENABLE_KUBERNETES),
|
||||
authProviders: enabledAuthProviders,
|
||||
|
||||
const response = await umamiInstance.track("server-analytics-v2", analyticsData);
|
||||
if (!response.ok) {
|
||||
logger.warn("Unable to send analytics to Umami instance");
|
||||
osPlatform: process.platform,
|
||||
osArch: process.arch,
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
defaultLocale: cultureSettings.defaultLocale,
|
||||
|
||||
countBoards,
|
||||
countGroups,
|
||||
countApps,
|
||||
countSections,
|
||||
countSearchEngines,
|
||||
countIconRepositories,
|
||||
countIcons,
|
||||
countLayouts,
|
||||
countItemLayouts,
|
||||
countSectionLayouts,
|
||||
countCronJobConfigs,
|
||||
countCustomWidgets,
|
||||
countTrustedCertificates,
|
||||
|
||||
countUsers,
|
||||
countApiKeys,
|
||||
countInvites,
|
||||
countMedias,
|
||||
countSessions,
|
||||
countAccounts,
|
||||
|
||||
countIntegrations: sumGroupedCounts(integrationKinds),
|
||||
countWidgets: sumGroupedCounts(widgetKinds),
|
||||
};
|
||||
|
||||
for (const row of integrationKinds) {
|
||||
properties[`integration_${row.kind}`] = row.count;
|
||||
}
|
||||
for (const row of widgetKinds) {
|
||||
properties[`widget_${row.kind}`] = row.count;
|
||||
}
|
||||
|
||||
client.capture({
|
||||
distinctId: instanceId,
|
||||
event: "server-analytics",
|
||||
properties,
|
||||
});
|
||||
|
||||
await client.flush();
|
||||
logger.info(`Sent analytics to PostHog in ${stopWatch.getElapsedInHumanWords()}`);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
logger.warn("Failed to send analytics to PostHog", { error });
|
||||
return "failed";
|
||||
}
|
||||
|
||||
logger.info(`Sent analytics in ${stopWatch.getElapsedInHumanWords()}`);
|
||||
};
|
||||
@@ -24,6 +24,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
analytics: lazy(() => import("./router/analytics").then((mod) => mod.analyticsRouter)),
|
||||
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||
customWidget: lazy(() => import("./router/custom-widget/custom-widget-router").then((mod) => mod.customWidgetRouter)),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import z from "zod/v4";
|
||||
|
||||
import { sendServerAnalyticsAsync, trackEvent } from "@homarr/analytics";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
const getInstanceId = async (): Promise<string | null> => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
const settings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
if (!settings.enableGeneral) return null;
|
||||
return settings.instanceId;
|
||||
};
|
||||
|
||||
export const analyticsRouter = createTRPCRouter({
|
||||
sendAnalytics: permissionRequiredProcedure.requiresPermission("admin").mutation(async () => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "External connections are disabled" });
|
||||
}
|
||||
return { status: await sendServerAnalyticsAsync() };
|
||||
}),
|
||||
|
||||
trackFeature: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
feature: z.string().max(128),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const instanceId = await getInstanceId();
|
||||
if (!instanceId) return;
|
||||
|
||||
trackEvent(instanceId, `feature:${input.feature}`, {
|
||||
...input.properties,
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const instanceId = await getInstanceId();
|
||||
if (!instanceId) return;
|
||||
|
||||
trackEvent(instanceId, "user-active", {
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
|
||||
})
|
||||
.input(iconsFindSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const term = input.searchText?.toLowerCase().trim() ?? "";
|
||||
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
||||
|
||||
let whereCondition = undefined;
|
||||
if (term.length > 0) {
|
||||
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
||||
}
|
||||
|
||||
return {
|
||||
icons: await ctx.db.query.iconRepositories.findMany({
|
||||
with: {
|
||||
icons: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
columns: { id: true, name: true, url: true },
|
||||
where: whereCondition,
|
||||
orderBy: (table, { asc, sql }) => {
|
||||
// ponytail: SVG first, then alphabetical; with a search term,
|
||||
// prepend a relevance tier (exact > prefix > substring).
|
||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||
const nameAsc = asc(table.name);
|
||||
if (term.length === 0) {
|
||||
return [svgFirst, nameAsc];
|
||||
}
|
||||
const tier = sql`CASE
|
||||
WHEN LOWER(${table.name}) = ${term} THEN 0
|
||||
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
||||
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
||||
ELSE 3
|
||||
END`;
|
||||
return [tier, svgFirst, nameAsc];
|
||||
},
|
||||
where:
|
||||
(input.searchText?.length ?? 0) > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
||||
: undefined,
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
integrationCreateSchema,
|
||||
@@ -469,6 +470,10 @@ export const integrationRouter = createTRPCRouter({
|
||||
kind: integration.kind,
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
// Invalidate all cached data for this integration so that widgets pick up the
|
||||
// new configuration immediately instead of serving stale (or errored) data.
|
||||
await invalidateIntegrationCacheAsync(input.id);
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
@@ -490,6 +495,9 @@ export const integrationRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
||||
|
||||
// Clean up any cached data left behind by the deleted integration.
|
||||
await invalidateIntegrationCacheAsync(input.id);
|
||||
}),
|
||||
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
|
||||
@@ -24,17 +24,18 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await updateServerSettingByKeyAsync(
|
||||
ctx.db,
|
||||
input.settingsKey,
|
||||
input.value as ServerSettings[keyof ServerSettings],
|
||||
);
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
...current,
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
.input(settingsInitSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
|
||||
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
|
||||
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
|
||||
@@ -75,9 +75,6 @@ describe("saveSettings", () => {
|
||||
settingsKey: "analytics",
|
||||
value: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,9 +84,7 @@ describe("saveSettings", () => {
|
||||
settingKey: "analytics",
|
||||
value: SuperJSON.stringify({
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
instanceId: null,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -14,6 +15,8 @@ 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({
|
||||
@@ -25,7 +28,9 @@ export const beszelRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
@@ -40,6 +45,29 @@ 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;
|
||||
}),
|
||||
|
||||
@@ -83,7 +111,14 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
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(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
@@ -100,6 +135,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
alerts: alertsResult.data.alerts,
|
||||
history: alertsResult.data.history,
|
||||
systemNameMap,
|
||||
@@ -107,6 +143,30 @@ 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;
|
||||
}),
|
||||
|
||||
@@ -164,13 +224,41 @@ export const beszelRouter = createTRPCRouter({
|
||||
if (!integration) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||
}
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
logger.debug("getSystemStats called", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
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),
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
@@ -190,6 +278,11 @@ 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();
|
||||
|
||||
@@ -199,6 +292,12 @@ 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 {
|
||||
@@ -210,6 +309,11 @@ export const beszelRouter = createTRPCRouter({
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
logger.debug("subscribeSystemStats closed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -36,6 +36,7 @@ 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)),
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -20,6 +20,24 @@ import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from ".
|
||||
|
||||
const logger = createLogger({ module: "authConfiguration" });
|
||||
|
||||
// All Auth.js cookies are prefixed with AUTH_COOKIE_PREFIX (default "homarr") so
|
||||
// Homarr's cookie jar is isolated from any other Auth.js/NextAuth app on the
|
||||
// same hostname (browsers scope cookies by host, not by port).
|
||||
// See https://github.com/homarr-labs/homarr/issues/5773
|
||||
const createCookies = (useSecureCookies: boolean) => {
|
||||
const prefix = env.AUTH_COOKIE_PREFIX;
|
||||
const securePrefix = useSecureCookies ? "__Secure-" : "";
|
||||
const hostPrefix = useSecureCookies ? "__Host-" : "";
|
||||
return {
|
||||
sessionToken: { name: sessionTokenCookieName },
|
||||
csrfToken: { name: `${hostPrefix}${prefix}.csrf-token` },
|
||||
callbackUrl: { name: `${securePrefix}${prefix}.callback-url` },
|
||||
pkceCodeVerifier: { name: `${securePrefix}${prefix}.pkce.cooldown` },
|
||||
state: { name: `${securePrefix}${prefix}.state` },
|
||||
nonce: { name: `${securePrefix}${prefix}.nonce` },
|
||||
};
|
||||
};
|
||||
|
||||
// See why it's unknown in the [...nextauth]/route.ts file
|
||||
export const createConfiguration = (
|
||||
provider: SupportedAuthProvider | "unknown",
|
||||
@@ -41,11 +59,7 @@ export const createConfiguration = (
|
||||
},
|
||||
},
|
||||
trustHost: true,
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: sessionTokenCookieName,
|
||||
},
|
||||
},
|
||||
cookies: createCookies(useSecureCookies),
|
||||
adapter,
|
||||
providers: filterProviders([
|
||||
Credentials(createCredentialsConfiguration(db)),
|
||||
|
||||
@@ -28,6 +28,11 @@ export const env = createEnv({
|
||||
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
|
||||
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
|
||||
AUTH_PROVIDERS: authProvidersSchema,
|
||||
AUTH_COOKIE_PREFIX: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-zA-Z0-9-_]+$/, "AUTH_COOKIE_PREFIX must only contain letters, numbers, hyphens and underscores")
|
||||
.default("homarr"),
|
||||
...(authProviders.includes("oidc")
|
||||
? {
|
||||
AUTH_OIDC_ISSUER: z.string().url(),
|
||||
|
||||
@@ -4,9 +4,11 @@ import { generateSecureRandomToken } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
|
||||
import { getCurrentUserPermissionsAsync } from "./callbacks";
|
||||
import { env } from "./env";
|
||||
|
||||
// Default of authjs
|
||||
export const sessionTokenCookieName = "authjs.session-token";
|
||||
// Prefixed (AUTH_COOKIE_PREFIX, default "homarr") to avoid cookie collisions
|
||||
// with other Auth.js apps on the same host. See https://github.com/homarr-labs/homarr/issues/5773
|
||||
export const sessionTokenCookieName = `${env.AUTH_COOKIE_PREFIX}.session-token`;
|
||||
|
||||
export const expireDateAfter = (seconds: number) => {
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
|
||||
@@ -20,6 +20,7 @@ interface CreateCronJobOptions {
|
||||
runOnStart?: boolean;
|
||||
preventManualExecution?: boolean;
|
||||
preventCustomInterval?: boolean;
|
||||
hidden?: boolean;
|
||||
expectedMaximumDurationInMillis?: number;
|
||||
beforeStart?: () => MaybePromise<void>;
|
||||
}
|
||||
@@ -121,6 +122,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
},
|
||||
preventManualExecution: options.preventManualExecution ?? false,
|
||||
preventCustomInterval: options.preventCustomInterval ?? false,
|
||||
hidden: options.hidden ?? false,
|
||||
timezone: creatorOptions.timezone,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
@@ -57,6 +58,7 @@ const getJobGroup = () => {
|
||||
timetable: timetableJob,
|
||||
tracearr: tracearrJob,
|
||||
umamiActiveVisitors: umamiActiveVisitorsJob,
|
||||
vpn: vpnJob,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -122,15 +122,17 @@ export class JobManager {
|
||||
> {
|
||||
const configurations = await this.db.query.cronJobConfigurations.findMany();
|
||||
|
||||
return [...this.jobGroup.getJobRegistry().entries()].map(([name, job]) => {
|
||||
const config = configurations.find((config) => config.name === name);
|
||||
return {
|
||||
name,
|
||||
cron: config?.cronExpression ?? job.cronExpression,
|
||||
preventManualExecution: job.preventManualExecution,
|
||||
preventCustomInterval: job.preventCustomInterval,
|
||||
isEnabled: config?.isEnabled ?? true,
|
||||
};
|
||||
});
|
||||
return [...this.jobGroup.getJobRegistry().entries()]
|
||||
.filter(([_, job]) => !job.hidden)
|
||||
.map(([name, job]) => {
|
||||
const config = configurations.find((config) => config.name === name);
|
||||
return {
|
||||
name,
|
||||
cron: config?.cronExpression ?? job.cronExpression,
|
||||
preventManualExecution: job.preventManualExecution,
|
||||
preventCustomInterval: job.preventCustomInterval,
|
||||
isEnabled: config?.isEnabled ?? true,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
@@ -10,13 +8,8 @@ export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
|
||||
runOnStart: true,
|
||||
preventManualExecution: true,
|
||||
preventCustomInterval: true,
|
||||
hidden: true,
|
||||
}).withCallback(async () => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return;
|
||||
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticSetting.enableGeneral) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendServerAnalyticsAsync();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
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: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -647,9 +647,13 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
|
||||
const seedDefaultCustomWidgetsAsync = async (db: Database) => {
|
||||
const seedIds = CUSTOM_WIDGET_SEEDS.map((s) => s.id);
|
||||
const beforeCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
|
||||
const existing = await db.query.customWidgetDefinitions.findMany({
|
||||
columns: { id: true },
|
||||
where: inArray(customWidgetDefinitions.id, seedIds),
|
||||
});
|
||||
const existingIds = new Set(existing.map((row) => row.id));
|
||||
|
||||
const seedValues = CUSTOM_WIDGET_SEEDS.map((seed) => {
|
||||
const seedValues = CUSTOM_WIDGET_SEEDS.filter((seed) => !existingIds.has(seed.id)).map((seed) => {
|
||||
const parsed = customWidgetImportSchema.parse(seed.data);
|
||||
return {
|
||||
id: seed.id,
|
||||
@@ -668,22 +672,14 @@ const seedDefaultCustomWidgetsAsync = async (db: Database) => {
|
||||
};
|
||||
});
|
||||
|
||||
if (seedValues.length === 0) return;
|
||||
|
||||
await db
|
||||
.insert(customWidgetDefinitions)
|
||||
.values(seedValues)
|
||||
.onConflictDoNothing({ target: customWidgetDefinitions.id });
|
||||
|
||||
const afterCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
|
||||
const createdCount = afterCount - beforeCount;
|
||||
|
||||
if (createdCount === 0) {
|
||||
if (seedValues.length === 0) {
|
||||
console.log("Skipping seeding of default custom widgets as they already exist");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Created ${createdCount} default custom widgets through seeding process`);
|
||||
await db.insert(customWidgetDefinitions).values(seedValues);
|
||||
|
||||
console.log(`Created ${seedValues.length} default custom widgets through seeding process`);
|
||||
};
|
||||
|
||||
const seedBoardWidgetsAsync = async (db: Database) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
|
||||
linuxServerIO: ["linuxserver"],
|
||||
coolify: ["coolify"],
|
||||
truenas: ["truenas"],
|
||||
fileFlows: ["fileflows"],
|
||||
};
|
||||
|
||||
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
|
||||
|
||||
@@ -165,6 +165,7 @@ 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"
|
||||
@@ -179,6 +180,7 @@ 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"
|
||||
@@ -280,6 +282,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
@@ -309,6 +312,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/dash-dot"
|
||||
| "/docs/integrations/glances"
|
||||
| "/docs/integrations/tdarr"
|
||||
| "/docs/integrations/fileflows"
|
||||
| "/docs/integrations/proxmox"
|
||||
| "/docs/integrations/nextcloud"
|
||||
| "/docs/integrations/unifi-controller"
|
||||
@@ -337,6 +341,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/audiobookshelf"
|
||||
| "/docs/integrations/navidrome"
|
||||
| "/docs/integrations/umami"
|
||||
| "/docs/integrations/gluetun"
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/widgets/clock"
|
||||
@@ -380,6 +385,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/audio-stats"
|
||||
| "/docs/widgets/umami"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/beszel-system-table"
|
||||
| "/docs/widgets/beszel-system-grid"
|
||||
|
||||
@@ -27,6 +27,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
dashDot: "dash-dot",
|
||||
glances: "glances",
|
||||
tdarr: "tdarr",
|
||||
fileFlows: "fileflows",
|
||||
proxmox: "proxmox",
|
||||
nextcloud: "nextcloud",
|
||||
unifiController: "unifi-controller",
|
||||
@@ -55,6 +56,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
audiobookshelf: "audiobookshelf",
|
||||
navidrome: "navidrome",
|
||||
umami: "umami",
|
||||
gluetun: "gluetun",
|
||||
peaNut: "peanut",
|
||||
beszel: "beszel",
|
||||
archiveTeamWarrior: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ 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",
|
||||
|
||||
@@ -251,6 +251,14 @@ export const integrationDefs = {
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"),
|
||||
defaultPort: 8265,
|
||||
},
|
||||
fileFlows: {
|
||||
name: "FileFlows",
|
||||
secretKinds: [[], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/fileflows.png",
|
||||
category: ["mediaTranscoding"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/fileflows"),
|
||||
defaultPort: 5000,
|
||||
},
|
||||
proxmox: {
|
||||
name: "Proxmox",
|
||||
secretKinds: [["username", "tokenId", "apiKey", "realm"]],
|
||||
@@ -489,13 +497,20 @@ export const integrationDefs = {
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||
defaultPort: 8090,
|
||||
},
|
||||
gluetun: {
|
||||
name: "Gluetun",
|
||||
secretKinds: [["username", "password"], ["apiKey"], []],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
|
||||
category: ["vpn"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
|
||||
},
|
||||
archiveTeamWarrior: {
|
||||
name: "ArchiveTeam Warrior",
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
category: ["archiving"],
|
||||
defaultUrl: "http://localhost:8001",
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
|
||||
},
|
||||
// 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: {
|
||||
@@ -604,6 +619,7 @@ export const integrationCategories = [
|
||||
"mediaMonitoring",
|
||||
"speedtest",
|
||||
"analytics",
|
||||
"vpn",
|
||||
"archiving",
|
||||
"ups",
|
||||
"documents",
|
||||
|
||||
@@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
speedtestTracker: ["speedtestTracker"],
|
||||
uptimeKuma: ["uptimeKuma"],
|
||||
audioStats: ["navidrome", "audiobookshelf"],
|
||||
vpn: getIntegrationKindsByCategory("vpn"),
|
||||
anchorNote: ["anchor"],
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const widgetKinds = [
|
||||
"uptimeKuma",
|
||||
"audioStats",
|
||||
"umami",
|
||||
"vpn",
|
||||
"archiveTeamWarrior",
|
||||
"ups",
|
||||
"beszelSystemTable",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Combobox,
|
||||
Flex,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -34,7 +37,6 @@ interface IconPickerProps {
|
||||
error?: string | null;
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
withAsterisk?: boolean;
|
||||
@@ -50,29 +52,30 @@ export const IconPicker = ({
|
||||
label,
|
||||
placeholder,
|
||||
}: IconPickerProps) => {
|
||||
const [value, setValue] = useUncontrolled({
|
||||
value: propsValue,
|
||||
onChange,
|
||||
});
|
||||
const [search, setSearch] = useUncontrolled({
|
||||
value,
|
||||
onChange: (value) => {
|
||||
setValue(value);
|
||||
},
|
||||
});
|
||||
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
||||
value: propsValue ?? null,
|
||||
});
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// ponytail: reset the typed query when the committed value changes
|
||||
// externally (form reset, parent re-render) so the input reverts to the URL.
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
}, [value]);
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 100);
|
||||
// ponytail: only search when the typed query differs from the committed
|
||||
// value. On mount the input shows the existing URL (edit mode), which
|
||||
// would never match the bare filenames in the icon table.
|
||||
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
|
||||
|
||||
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
||||
const { data } = clientApi.icon.findIcons.useQuery({
|
||||
searchText: debouncedSearch,
|
||||
});
|
||||
// ponytail: not useSuspenseQuery — an above Suspense boundary would
|
||||
// trigger on every keystroke and the dropdown flash is bad UX.
|
||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText },
|
||||
{ placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
@@ -90,25 +93,15 @@ export const IconPicker = ({
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
const value = item.url;
|
||||
startTransition(() => {
|
||||
setPreviewUrl(value);
|
||||
setSearch(value);
|
||||
setValue(value);
|
||||
setValue(item.url);
|
||||
setQuery("");
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card
|
||||
p="sm"
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={classes.iconCard}
|
||||
>
|
||||
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} />
|
||||
</Box>
|
||||
@@ -130,6 +123,48 @@ export const IconPicker = ({
|
||||
);
|
||||
});
|
||||
|
||||
// ponytail: show the typed query if non-empty, else fall back to the URL.
|
||||
const inputValue = query || value || "";
|
||||
// ponytail: show the existing icon as a left-section preview.
|
||||
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
||||
// ponytail: prop override -> real count -> loading text.
|
||||
const placeholderText =
|
||||
placeholder ||
|
||||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
||||
tCommon("iconPicker.headerLoading");
|
||||
|
||||
const renderDropdown = () => {
|
||||
if (isError) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconSearchOff size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
||||
<Button size="xs" variant="default" onClick={() => refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return Array(15)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
));
|
||||
}
|
||||
if (totalOptions > 0) {
|
||||
return <Stack gap={4}>{groups}</Stack>;
|
||||
}
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconMoodEmpty size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
@@ -137,16 +172,11 @@ export const IconPicker = ({
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={
|
||||
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
|
||||
}
|
||||
value={search}
|
||||
leftSection={leftSection}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
setPreviewUrl(null);
|
||||
setQuery(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
@@ -156,25 +186,21 @@ export const IconPicker = ({
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setPreviewUrl(value);
|
||||
setSearch(value || "");
|
||||
setQuery("");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
||||
placeholder={placeholderText}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
setValue(first.url);
|
||||
setPreviewUrl(first.url);
|
||||
setSearch(first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -190,17 +216,7 @@ export const IconPicker = ({
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{totalOptions > 0 ? (
|
||||
<Stack gap={4}>{groups}</Stack>
|
||||
) : (
|
||||
Array(15)
|
||||
.fill(0)
|
||||
.map((_, index: number) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
{renderDropdown()}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
@@ -209,8 +225,8 @@ export const IconPicker = ({
|
||||
|
||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
|
||||
const shouldShowPreview = (value: string | null): value is string => {
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
||||
if (!value) return false;
|
||||
return !localizationPathRegex.test(value);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
@@ -32,6 +33,7 @@ import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"
|
||||
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
||||
import { FileFlowsIntegration } from "../media-transcoding/fileflows-integration";
|
||||
import { MockIntegration } from "../mock/mock-integration";
|
||||
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
|
||||
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
||||
@@ -108,6 +110,7 @@ export const integrationCreators = {
|
||||
readarr: ReadarrIntegration,
|
||||
dashDot: DashDotIntegration,
|
||||
tdarr: TdarrIntegration,
|
||||
fileFlows: FileFlowsIntegration,
|
||||
proxmox: ProxmoxIntegration,
|
||||
emby: EmbyIntegration,
|
||||
nextcloud: NextcloudIntegration,
|
||||
@@ -137,6 +140,7 @@ export const integrationCreators = {
|
||||
audiobookshelf: AudiobookshelfIntegration,
|
||||
navidrome: NavidromeIntegration,
|
||||
umami: UmamiIntegration,
|
||||
gluetun: GluetunIntegration,
|
||||
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
|
||||
@@ -46,10 +46,13 @@ 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 response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -57,23 +60,28 @@ 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: options.method ?? "GET",
|
||||
method,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
@@ -82,14 +90,42 @@ 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);
|
||||
}
|
||||
|
||||
@@ -257,6 +293,7 @@ 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 },
|
||||
@@ -264,8 +301,14 @@ 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();
|
||||
@@ -322,6 +365,10 @@ 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 {
|
||||
|
||||
@@ -38,7 +38,7 @@ const itemSchema = z.object({
|
||||
ServerId: z.string(),
|
||||
Name: z.string(),
|
||||
Taglines: z.array(z.string()),
|
||||
Studios: z.array(z.object({ Name: z.string() })),
|
||||
Studios: z.array(z.object({ Name: z.string() })).optional(),
|
||||
Overview: z.string().optional(),
|
||||
PremiereDate: z
|
||||
.string()
|
||||
@@ -172,7 +172,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||
},
|
||||
producer: item.Studios.at(0)?.Name,
|
||||
producer: item.Studios?.at(0)?.Name,
|
||||
rating: item.CommunityRating?.toFixed(1),
|
||||
tags: item.Genres,
|
||||
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { VpnSummaryIntegration } from "../interfaces/vpn-summary/vpn-summary-integration";
|
||||
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
|
||||
import {
|
||||
gluetunDnsStatusSchema,
|
||||
gluetunPublicIpSchema,
|
||||
gluetunVpnSettingsSchema,
|
||||
gluetunVpnStatusSchema,
|
||||
} from "./gluetun-types";
|
||||
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
|
||||
|
||||
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<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 {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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>;
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { GluetunIntegration } from "../gluetun-integration";
|
||||
import {
|
||||
gluetunDnsStatusSchema,
|
||||
gluetunPublicIpSchema,
|
||||
gluetunVpnSettingsSchema,
|
||||
gluetunVpnStatusSchema,
|
||||
} from "../gluetun-types";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<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,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte
|
||||
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
|
||||
const parsed = result.data;
|
||||
|
||||
return parsed.messages.map((message): Notification => ({
|
||||
id: String(message.id),
|
||||
time: new Date(message.date),
|
||||
title: message.title,
|
||||
body: message.message,
|
||||
}));
|
||||
return parsed.messages.map(
|
||||
(message): Notification => ({
|
||||
id: String(message.id),
|
||||
time: new Date(message.date),
|
||||
title: message.title,
|
||||
body: message.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { VpnSummary } from "./vpn-summary-types";
|
||||
|
||||
export interface VpnSummaryIntegration {
|
||||
getSummaryAsync(): Promise<VpnSummary>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface VpnProviderDetails {
|
||||
protocol: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface VpnSummary {
|
||||
vpnStatus: string;
|
||||
dnsStatus: string;
|
||||
publicIp: string;
|
||||
country: string;
|
||||
city: string;
|
||||
vpnProvider: VpnProviderDetails;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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 { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/media-transcoding/media-transcoding-types";
|
||||
import {
|
||||
libraryStatusSchema,
|
||||
recentlyFinishedSchema,
|
||||
shrinkageGroupsSchema,
|
||||
statusResponseSchema,
|
||||
upcomingFilesSchema,
|
||||
workersResponseSchema,
|
||||
} from "./fileflows-validation-schemas";
|
||||
|
||||
export class FileFlowsIntegration extends Integration implements IMediaTranscodingIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/system/version"), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getStatisticsAsync(): Promise<TdarrStatistics> {
|
||||
const headerParams = {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
|
||||
const [statusData, libraryStatusData, shrinkageData] = await Promise.all([
|
||||
this.fetchStatusAsync(headerParams),
|
||||
this.fetchLibraryStatusAsync(headerParams),
|
||||
this.fetchShrinkageAsync(headerParams),
|
||||
]);
|
||||
|
||||
const failedCount = libraryStatusData
|
||||
.filter((item) => item.Status === 4)
|
||||
.reduce((sum, item) => sum + item.Count, 0);
|
||||
const processedCount = libraryStatusData
|
||||
.filter((item) => item.Status === 3)
|
||||
.reduce((sum, item) => sum + item.Count, 0);
|
||||
const queuedCount = libraryStatusData
|
||||
.filter((item) => item.Status === 1 || item.Status === 5)
|
||||
.reduce((sum, item) => sum + item.Count, 0);
|
||||
|
||||
const totalSavedSpace = Object.values(shrinkageData).reduce(
|
||||
(sum, group) => sum + Math.max(0, group.OriginalSize - group.FinalSize),
|
||||
0,
|
||||
);
|
||||
|
||||
const transcodeStatus = [
|
||||
{ name: "Processed", value: statusData.processed },
|
||||
{ name: "Queued", value: statusData.queue },
|
||||
{ name: "Processing", value: statusData.processing },
|
||||
{ name: "Failed", value: failedCount },
|
||||
];
|
||||
|
||||
return {
|
||||
libraryName: "All",
|
||||
totalFileCount: processedCount + queuedCount + statusData.processing + failedCount,
|
||||
totalTranscodeCount: statusData.processed,
|
||||
totalHealthCheckCount: 0,
|
||||
failedTranscodeCount: failedCount,
|
||||
failedHealthCheckCount: 0,
|
||||
stagedTranscodeCount: statusData.queue,
|
||||
stagedHealthCheckCount: 0,
|
||||
totalSavedSpace,
|
||||
transcodeStatus,
|
||||
healthCheckStatus: [],
|
||||
videoCodecs: [],
|
||||
videoContainers: [],
|
||||
videoResolutions: [],
|
||||
audioCodecs: [],
|
||||
audioContainers: [],
|
||||
};
|
||||
}
|
||||
|
||||
public async getWorkersAsync(): Promise<TdarrWorker[]> {
|
||||
const headerParams = {
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
|
||||
const url = this.url("/api/worker");
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "GET",
|
||||
headers: headerParams,
|
||||
});
|
||||
|
||||
const workersData = await workersResponseSchema.parseAsync(await response.json());
|
||||
|
||||
return workersData.map((worker) => ({
|
||||
id: worker.Uid,
|
||||
filePath: worker.RelativeFile ?? "",
|
||||
fps: 0,
|
||||
percentage: worker.CurrentPartPercent,
|
||||
ETA: worker.ProcessingTime?.TotalSeconds ? `${Math.round(worker.ProcessingTime.TotalSeconds)}s` : "",
|
||||
jobType: worker.CurrentPartName ?? "Processing",
|
||||
status: "Processing",
|
||||
step: worker.CurrentPartName ?? "",
|
||||
originalSize: worker.LibraryFile?.OriginalSize ?? 0,
|
||||
estimatedSize: null,
|
||||
outputSize: null,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getQueueAsync(firstItemIndex: number, pageSize: number): Promise<TdarrQueue> {
|
||||
const headerParams = {
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
|
||||
const [upcomingItems, recentlyFinishedItems] = await Promise.all([
|
||||
this.fetchUpcomingAsync(headerParams),
|
||||
this.fetchRecentlyFinishedAsync(headerParams),
|
||||
]);
|
||||
|
||||
const queueArray = [
|
||||
...upcomingItems.map((item) => ({
|
||||
id: item.Uid,
|
||||
healthCheck: "",
|
||||
transcode: item.Flow ?? "Default",
|
||||
filePath: item.RelativePath ?? item.Name ?? "",
|
||||
fileSize: item.OriginalSize ?? 0,
|
||||
container: "",
|
||||
codec: "",
|
||||
resolution: "",
|
||||
type: "transcode" as const,
|
||||
})),
|
||||
...recentlyFinishedItems.map((item) => ({
|
||||
id: item.Uid,
|
||||
healthCheck: "",
|
||||
transcode: item.Flow ?? "Default",
|
||||
filePath: item.RelativePath ?? item.Name ?? "",
|
||||
fileSize: item.FinalSize ?? item.OriginalSize ?? 0,
|
||||
container: "",
|
||||
codec: "",
|
||||
resolution: "",
|
||||
type: "health-check" as const,
|
||||
})),
|
||||
].slice(0, pageSize);
|
||||
|
||||
return {
|
||||
array: queueArray,
|
||||
totalCount: upcomingItems.length + recentlyFinishedItems.length,
|
||||
startIndex: firstItemIndex,
|
||||
endIndex: firstItemIndex + queueArray.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchStatusAsync(headerParams: Record<string, string>) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/status"), {
|
||||
method: "GET",
|
||||
headers: headerParams,
|
||||
});
|
||||
return statusResponseSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
private async fetchLibraryStatusAsync(headerParams: Record<string, string>) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/status"), {
|
||||
method: "GET",
|
||||
headers: headerParams,
|
||||
});
|
||||
return libraryStatusSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
private async fetchShrinkageAsync(headerParams: Record<string, string>) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/shrinkage-groups"), {
|
||||
method: "GET",
|
||||
headers: headerParams,
|
||||
});
|
||||
return shrinkageGroupsSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
private async fetchUpcomingAsync(headerParams: Record<string, string>) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/upcoming"), {
|
||||
method: "GET",
|
||||
headers: headerParams,
|
||||
});
|
||||
return upcomingFilesSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
private async fetchRecentlyFinishedAsync(headerParams: Record<string, string>) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/recently-finished"), {
|
||||
method: "GET",
|
||||
headers: headerParams,
|
||||
});
|
||||
return recentlyFinishedSchema.parseAsync(await response.json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const statusResponseSchema = z.object({
|
||||
queue: z.number(),
|
||||
processing: z.number(),
|
||||
processed: z.number(),
|
||||
processingFiles: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().nullable(),
|
||||
relativePath: z.string().nullable(),
|
||||
library: z.string().nullable(),
|
||||
step: z.string().nullable(),
|
||||
stepPercent: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.transform((arr) => arr ?? []),
|
||||
});
|
||||
|
||||
export const libraryStatusSchema = z.array(
|
||||
z.object({
|
||||
Name: z.string().nullable(),
|
||||
Status: z.number(),
|
||||
Count: z.number(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const shrinkageGroupsSchema = z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
Library: z.string().nullable(),
|
||||
OriginalSize: z.number(),
|
||||
FinalSize: z.number(),
|
||||
Items: z.number(),
|
||||
Difference: z.number().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const workersResponseSchema = z.array(
|
||||
z.object({
|
||||
Uid: z.string(),
|
||||
NodeName: z.string().nullable(),
|
||||
RelativeFile: z.string().nullable(),
|
||||
CurrentPart: z.number(),
|
||||
CurrentPartName: z.string().nullable(),
|
||||
CurrentPartPercent: z.number(),
|
||||
StartedAt: z.string(),
|
||||
LastUpdate: z.string(),
|
||||
ProcessingTime: z
|
||||
.object({
|
||||
TotalSeconds: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
LibraryFile: z
|
||||
.object({
|
||||
OriginalSize: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
Library: z
|
||||
.object({
|
||||
Name: z.string().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const upcomingFilesSchema = z.array(
|
||||
z.object({
|
||||
Uid: z.string(),
|
||||
Name: z.string().nullable(),
|
||||
RelativePath: z.string().nullable(),
|
||||
OriginalSize: z.number().nullable(),
|
||||
FinalSize: z.number().nullable(),
|
||||
Status: z.number(),
|
||||
Library: z.string().nullable(),
|
||||
Flow: z.string().nullable(),
|
||||
Node: z.string().nullable(),
|
||||
ProcessingTime: z
|
||||
.object({
|
||||
TotalSeconds: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const recentlyFinishedSchema = z.array(
|
||||
z.object({
|
||||
Uid: z.string(),
|
||||
Name: z.string().nullable(),
|
||||
RelativePath: z.string().nullable(),
|
||||
OriginalSize: z.number().nullable(),
|
||||
FinalSize: z.number().nullable(),
|
||||
Status: z.number(),
|
||||
Library: z.string().nullable(),
|
||||
Flow: z.string().nullable(),
|
||||
}),
|
||||
);
|
||||
@@ -127,11 +127,18 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
|
||||
const media =
|
||||
data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => {
|
||||
const title =
|
||||
item.type === "episode"
|
||||
? (item.grandparentTitle ?? item.title)
|
||||
: item.type === "season"
|
||||
? (item.parentTitle ?? item.title)
|
||||
: item.title;
|
||||
|
||||
return {
|
||||
id: item.Media?.at(0)?.id.toString() ?? item.key,
|
||||
type: mapType(item.type),
|
||||
title: item.title,
|
||||
subtitle: item.tagline,
|
||||
title,
|
||||
subtitle: title === item.title ? item.tagline : item.title,
|
||||
description: item.summary,
|
||||
releaseDate: item.originallyAvailableAt
|
||||
? new Date(item.originallyAvailableAt)
|
||||
@@ -222,6 +229,8 @@ const recentlyAddedSchema = z.object({
|
||||
studio: z.string().optional(),
|
||||
type: z.string(), // For example "movie", "album"
|
||||
title: z.string(),
|
||||
parentTitle: z.string().optional(),
|
||||
grandparentTitle: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
duration: z.number().optional(),
|
||||
addedAt: z.number(),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment node
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { PlexIntegration } from "../plex-integration";
|
||||
|
||||
const imageProxyMocks = vi.hoisted(() => ({
|
||||
createImageAsync: vi.fn((url: string) => Promise.resolve(`proxied:${url}`)),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/image-proxy", () => ({
|
||||
ImageProxy: class {
|
||||
createImageAsync = imageProxyMocks.createImageAsync;
|
||||
},
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://plex.example.com";
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "plex-token" }];
|
||||
|
||||
const createIntegration = () =>
|
||||
new PlexIntegration({
|
||||
id: "test-plex",
|
||||
name: "Test Plex",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: secrets,
|
||||
});
|
||||
|
||||
const createMetadataItem = (key: string, overrides: Record<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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -132,8 +132,9 @@ const happyResponder = (method: string) => {
|
||||
aggregations: emptyAggregations(),
|
||||
start: 0,
|
||||
end: 1,
|
||||
legend: [],
|
||||
data: [[1000, 8_000_000_000]],
|
||||
// The "memory" graph reports available (free) memory: legend is ["time", "available"].
|
||||
legend: ["time", "available"],
|
||||
data: [[1000, 6_000_000_000]],
|
||||
},
|
||||
{
|
||||
name: "cputemp",
|
||||
@@ -194,15 +195,17 @@ describe("TrueNasIntegration", () => {
|
||||
version: "TrueNAS-SCALE-24.10",
|
||||
cpuModelName: "Intel Xeon",
|
||||
cpuTemp: 45,
|
||||
memAvailableInBytes: 16_000_000_000,
|
||||
memUsedInBytes: 8_000_000_000,
|
||||
// physmem (16 GB) minus the reported available 6 GB leaves 10 GB used.
|
||||
memAvailableInBytes: 6_000_000_000,
|
||||
memUsedInBytes: 10_000_000_000,
|
||||
uptime: 3600,
|
||||
rebootRequired: false,
|
||||
availablePkgUpdates: 0,
|
||||
loadAverage: null,
|
||||
gpu: [],
|
||||
network: { up: 20_000, down: 10_000 },
|
||||
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
|
||||
// `available` is the free space left on the pool (free = 500), not its total size.
|
||||
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,14 +37,19 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
|
||||
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
|
||||
|
||||
// The "memory" reporting graph reports available (free) memory, its legend is ["time", "available"].
|
||||
// Used memory is therefore the physical total minus what is still available (clamped against timing skew).
|
||||
const memAvailableInBytes = memoryData[1] ?? 0; // Index 0 is the UNIX timestamp, Index 1 is available bytes
|
||||
const memUsedInBytes = Math.max(systemInformation.physmem - memAvailableInBytes, 0);
|
||||
|
||||
return {
|
||||
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
||||
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
||||
memAvailableInBytes: systemInformation.physmem,
|
||||
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
||||
memAvailableInBytes,
|
||||
memUsedInBytes,
|
||||
fileSystem: datasets.map((dataset) => ({
|
||||
deviceName: dataset.name,
|
||||
available: `${dataset.size}`, // TODO: can we use number instead of string here?
|
||||
available: `${dataset.free}`, // free space left on the pool
|
||||
used: `${dataset.allocated}`,
|
||||
percentage: (dataset.allocated / dataset.size) * 100,
|
||||
})),
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
|
||||
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
||||
export * from "./interfaces/health-monitoring/health-monitoring-types";
|
||||
export * from "./interfaces/ups-summary/ups-summary-types";
|
||||
export * from "./interfaces/vpn-summary/vpn-summary-types";
|
||||
export * from "./interfaces/indexer-manager/indexer-manager-types";
|
||||
export * from "./interfaces/media-requests/media-request-types";
|
||||
export * from "./base/searchable-integration";
|
||||
@@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
|
||||
export * from "./navidrome/navidrome-types";
|
||||
export * from "./paperless-ngx/paperless-ngx-types";
|
||||
export * from "./umami/umami-types";
|
||||
export * from "./gluetun/gluetun-types";
|
||||
export * from "./beszel/beszel-types";
|
||||
@@ -59,8 +59,8 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
||||
fileSystem: systemInfo.array.disks.map((disk) => ({
|
||||
deviceName: disk.name,
|
||||
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), covert to bytes
|
||||
available: `${disk.size * 1024}`, // API is in KiB (kibibytes), covert to bytes
|
||||
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
|
||||
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
|
||||
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
||||
})),
|
||||
smart: systemInfo.array.disks.map((disk) => ({
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
import {
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual =
|
||||
await importActual<
|
||||
typeof import("@homarr/core/infrastructure/certificates")
|
||||
>();
|
||||
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||
return {
|
||||
...actual,
|
||||
getTrustedCertificateHostnamesAsync: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve([])),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
|
||||
|
||||
const makeInput = (
|
||||
baseUrl: string,
|
||||
auth:
|
||||
| { kind: "credentials"; password?: string }
|
||||
| { kind: "apiKey"; value: string },
|
||||
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
|
||||
): IntegrationInput => ({
|
||||
id: INTEGRATION_ID,
|
||||
name: "Technitium DNS",
|
||||
@@ -109,10 +94,7 @@ const makeInput = (
|
||||
|
||||
// Fetch an API token appropriate for the server version.
|
||||
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
|
||||
async function fetchApiToken(
|
||||
baseUrl: string,
|
||||
apiVersion: TechnitiumVersion,
|
||||
): Promise<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(
|
||||
@@ -122,9 +104,7 @@ async function fetchApiToken(
|
||||
}
|
||||
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
|
||||
// token that can be used as a long-lived API key via the ?token= query parameter.
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
|
||||
);
|
||||
const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
}
|
||||
|
||||
@@ -144,16 +124,12 @@ const pool: Record<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);
|
||||
@@ -163,238 +139,209 @@ beforeAll(async () => {
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(
|
||||
Object.values(pool).map(({ container }) => container.stop()),
|
||||
);
|
||||
await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
|
||||
});
|
||||
|
||||
// ─── version-parameterised suites ────────────────────────────────────────────
|
||||
|
||||
describe.each(VERSIONS)(
|
||||
"Technitium DNS $tag ($apiVersion)",
|
||||
({ tag, apiVersion }) => {
|
||||
let baseUrl: string;
|
||||
let apiToken: string;
|
||||
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
|
||||
let baseUrl: string;
|
||||
let apiToken: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const entry = pool[tag];
|
||||
if (!entry)
|
||||
throw new Error(`Container pool entry for ${tag} was not initialized`);
|
||||
({ baseUrl, apiToken } = entry);
|
||||
});
|
||||
beforeAll(() => {
|
||||
const entry = pool[tag];
|
||||
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
|
||||
({ baseUrl, apiToken } = entry);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionState[INTEGRATION_ID] = null;
|
||||
});
|
||||
beforeEach(() => {
|
||||
sessionState[INTEGRATION_ID] = null;
|
||||
});
|
||||
|
||||
// ── factory ────────────────────────────────────────────────────────────────
|
||||
// ── factory ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
||||
test("factory creates a working integration (auto-detects version)", async () => {
|
||||
const integration = await createTechnitiumDnsIntegrationAsync(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
||||
test("factory creates a working integration (auto-detects version)", async () => {
|
||||
const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
||||
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
||||
|
||||
describe("getSummaryAsync", () => {
|
||||
test("returns valid summary with credentials", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("getSummaryAsync", () => {
|
||||
test("returns valid summary with credentials", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
|
||||
test("returns valid summary with API key", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
);
|
||||
test("returns valid summary with API key", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
);
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
|
||||
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(typeof result.domainsBeingBlocked).toBe("number");
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(typeof result.domainsBeingBlocked).toBe("number");
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── session caching ────────────────────────────────────────────────────────
|
||||
// ── session caching ────────────────────────────────────────────────────────
|
||||
|
||||
describe("session caching", () => {
|
||||
test("login is performed once and token is cached for subsequent calls", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("session caching", () => {
|
||||
test("login is performed once and token is cached for subsequent calls", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
||||
expect(tokenAfterFirst).toBeDefined();
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
||||
expect(tokenAfterFirst).toBeDefined();
|
||||
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
||||
|
||||
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
||||
}, 20_000);
|
||||
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
||||
}, 20_000);
|
||||
|
||||
test("recovers from an expired/invalid stored token by re-logging in", async () => {
|
||||
// Plant a bad token with the correct version so it hits the right API path before failing
|
||||
sessionState[INTEGRATION_ID] = {
|
||||
token: "deliberately-invalid-token",
|
||||
version: apiVersion,
|
||||
};
|
||||
test("recovers from an expired/invalid stored token by re-logging in", async () => {
|
||||
// Plant a bad token with the correct version so it hits the right API path before failing
|
||||
sessionState[INTEGRATION_ID] = {
|
||||
token: "deliberately-invalid-token",
|
||||
version: apiVersion,
|
||||
};
|
||||
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
apiVersion,
|
||||
);
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
||||
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
|
||||
"deliberately-invalid-token",
|
||||
);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
||||
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── blocking state changes ─────────────────────────────────────────────────
|
||||
// ── blocking state changes ─────────────────────────────────────────────────
|
||||
|
||||
describe("blocking state changes", () => {
|
||||
test("disableAsync permanently disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("blocking state changes", () => {
|
||||
test("disableAsync permanently disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("enableAsync re-enables blocking after permanent disable", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("enableAsync re-enables blocking after permanent disable", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync();
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync();
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
|
||||
test("disableAsync with duration temporarily disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("disableAsync with duration temporarily disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(120); // 120s → 2 min
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(120); // 120s → 2 min
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(1);
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(1);
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("enableAsync cancels an active timed disable immediately", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("enableAsync cancels an active timed disable immediately", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(600); // 10 minutes
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(600); // 10 minutes
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── testConnectionAsync ────────────────────────────────────────────────────
|
||||
// ── testConnectionAsync ────────────────────────────────────────────────────
|
||||
|
||||
describe("testConnectionAsync", () => {
|
||||
test("succeeds with valid credentials", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
describe("testConnectionAsync", () => {
|
||||
test("succeeds with valid credentials", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
test("succeeds with valid API key", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
test("succeeds with valid API key", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
test("fails with wrong password — returns authorization error", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, {
|
||||
kind: "credentials",
|
||||
password: "wrong-password",
|
||||
}),
|
||||
);
|
||||
test("fails with wrong password — returns authorization error", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, {
|
||||
kind: "credentials",
|
||||
password: "wrong-password",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await integration.testConnectionAsync();
|
||||
const result = await integration.testConnectionAsync();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
|
||||
test("fails with invalid API key — returns authorization error", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
test("fails with invalid API key — returns authorization error", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
createGetSetChannel,
|
||||
createQueryCacheChannel,
|
||||
getAllQueryCacheAsync,
|
||||
invalidateIntegrationCacheAsync,
|
||||
} from "./lib/channel";
|
||||
|
||||
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
|
||||
|
||||
@@ -261,6 +261,36 @@ export const createIntegrationOptionsChannel = <TData>(
|
||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidates all cached data for a given integration by deleting every Redis key
|
||||
* that starts with `integration:${integrationId}:` as well as the integration's
|
||||
* session store entry. This ensures that stale data from a misconfigured or
|
||||
* updated integration is never served from cache.
|
||||
*/
|
||||
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patterns = [`integration:${integrationId}:*`, `session-store:${integrationId}`];
|
||||
let deletedCount = 0;
|
||||
for (const pattern of patterns) {
|
||||
let cursor = "0";
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
|
||||
cursor = nextCursor;
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys);
|
||||
deletedCount += keys.length;
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
}
|
||||
if (deletedCount > 0) {
|
||||
logger.info("Invalidated integration cache", { integrationId, deletedKeys: deletedCount });
|
||||
}
|
||||
};
|
||||
|
||||
export const createWidgetOptionsChannel = <TData>(
|
||||
widgetKind: WidgetKind,
|
||||
queryKey: string,
|
||||
@@ -385,8 +415,18 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
|
||||
});
|
||||
},
|
||||
publishAndUpdateLastStateAsync: async (data: TData) => {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
// Set first, then publish. If publish fails the storage is still consistent
|
||||
// and subscribers will catch up on the next event.
|
||||
const payload = superjson.stringify({ data, timestamp: new Date() });
|
||||
await getSetClient.set(channelName, payload);
|
||||
try {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
} catch (error) {
|
||||
logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
|
||||
channel: channelName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
setAsync: async (data: TData) => {
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type {
|
||||
BeszelAlert,
|
||||
@@ -13,6 +14,8 @@ import type {
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "beszelRequestHandler" });
|
||||
|
||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||
|
||||
/**
|
||||
@@ -56,6 +59,7 @@ 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(
|
||||
@@ -64,9 +68,16 @@ 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(5, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelSystems",
|
||||
});
|
||||
|
||||
@@ -81,22 +92,31 @@ 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(15, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelAlerts",
|
||||
});
|
||||
|
||||
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: "120m", perPage: 84, cacheSeconds: 1800 },
|
||||
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
|
||||
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 },
|
||||
};
|
||||
|
||||
export interface BeszelStatsData {
|
||||
@@ -110,19 +130,25 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
||||
const start = performance.now();
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 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(60, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelStats",
|
||||
cacheDurationForInput(input) {
|
||||
const config = timePeriodConfig[input.timePeriod];
|
||||
if (!config) return undefined;
|
||||
return dayjs.duration(config.cacheSeconds, "seconds");
|
||||
},
|
||||
});
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user