Compare commits

..
Author SHA1 Message Date
ajnart 84f7ed97fc style: auto-fix lint and format 2026-06-17 14:11:52 +00:00
Thomas Camlong dc1e1891fb fix: persist Beszel server selection and improve picker visibility
The server picker was invisible (opacity: 0, hover-only) and selections
were lost on refresh because setOptions only updated the React Query cache.
Add a generic saveItemOptions mutation that persists widget option changes
directly to the database, and replace the hidden icon with a visible button.
2026-06-17 16:09:32 +02:00
213 changed files with 5876 additions and 12751 deletions

No files matched your search

-4
View File
@@ -33,10 +33,6 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.68.0
- 1.67.0
- 1.66.1
- 1.66.0
- 1.65.0
- 1.64.0
- 1.62.0
-62
View File
@@ -163,65 +163,3 @@ jobs:
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
memory-regression:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-memory-test
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Start container and measure memory
run: |
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
# Wait for container to be healthy (up to 60s)
healthy=false
for i in $(seq 1 30); do
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs homarr-mem-test --tail 50
exit 1
fi
# Warm up: hit the dashboard page a few times
for i in $(seq 1 5); do
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
sleep 2
done
# Measure RSS after warmup
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
echo "Raw memory: $MEM_KB"
# Convert to numeric MB (handles GiB and MiB suffixes)
MEM_NUM=$(echo "$MEM_KB" | awk '{
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
else { print $1 / 1048576 }
}')
echo "Memory usage: ${MEM_NUM} MB"
MAX_MB=700
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
docker logs homarr-mem-test --tail 50
exit 1
fi
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.18.0
node-version: 24.16.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
@@ -3,7 +3,6 @@ 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
View File
@@ -1,2 +1,2 @@
[tools]
node = "24.18.0"
node = "24.16.0"
+1 -1
View File
@@ -1 +1 @@
24.18.0
24.16.0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"oxc.oxc-vscode",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint",
"lokalise.i18n-ally"
-46
View File
@@ -1,49 +1,3 @@
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
### Bug Fixes
* make source maps opt-in to restore v1.67.0 memory baseline ([57ef0af](https://github.com/homarr-labs/homarr/commit/57ef0af4a252ea39727caeba7e13587dabc6254e)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
* revert production source maps to restore v1.67.0 memory baseline ([60a69e3](https://github.com/homarr-labs/homarr/commit/60a69e31efc7168b8f4309daf75954d7730d883b)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
### Features
* add VPN status widget ([#5812](https://github.com/homarr-labs/homarr/issues/5812)) ([a2bb53a](https://github.com/homarr-labs/homarr/commit/a2bb53a8b8fd17da3d2e23851b75d4e179f66225))
* re-add Docker quick-access button in user menu ([#6054](https://github.com/homarr-labs/homarr/issues/6054)) ([abd1635](https://github.com/homarr-labs/homarr/commit/abd16354bc9ef3dddbb497fd16b03476535046ac)), closes [#2165](https://github.com/homarr-labs/homarr/issues/2165)
* ship source maps for readable production stack traces ([#6031](https://github.com/homarr-labs/homarr/issues/6031)) ([89380f8](https://github.com/homarr-labs/homarr/commit/89380f81ddd132f5b1041a8f2c3b6c6b138923dd))
### Bug Fixes
* **beszel:** harden cache layer and prevent blank widgets ([#6052](https://github.com/homarr-labs/homarr/issues/6052)) ([49c2663](https://github.com/homarr-labs/homarr/commit/49c266374df12fea2404710232ced42e2de11dbf)), closes [#6030](https://github.com/homarr-labs/homarr/issues/6030)
* **beszel:** improve stats date display, add click-to-stats modal, fix grid ordering ([#6026](https://github.com/homarr-labs/homarr/issues/6026)) ([55fb227](https://github.com/homarr-labs/homarr/commit/55fb227305fe51963e486ffb25c4ce6811a2f0e4))
* **beszel:** leverage TanStack Query for stale-while-revalidate caching ([#6055](https://github.com/homarr-labs/homarr/issues/6055)) ([bbc4589](https://github.com/homarr-labs/homarr/commit/bbc4589c9e1608721fb58a6f9b03cca8d65c1c4a))
* **beszel:** use 480m interval for 1w/30d stats views ([4383da3](https://github.com/homarr-labs/homarr/commit/4383da38ef634327c288626c58480cc1e5a2f805))
* **beszel:** widget transparency, error handling, and cache invalidation ([#5993](https://github.com/homarr-labs/homarr/issues/5993)) ([39e4245](https://github.com/homarr-labs/homarr/commit/39e424563a3187dcf0dba5f654e2225be76aef51))
* **icon-picker:** keep URL visible in edit mode, add SVG-first ordering ([#6051](https://github.com/homarr-labs/homarr/issues/6051)) ([e01571b](https://github.com/homarr-labs/homarr/commit/e01571b221812cd19a6d67ebe3cf44432104ec27))
* **integrations:** prevent certificate trust button from submitting the form ([#6020](https://github.com/homarr-labs/homarr/issues/6020)) ([0f66bfd](https://github.com/homarr-labs/homarr/commit/0f66bfd6c128253ba644d80d7c908de5314cf997))
* **manage/about:** bypass self-fetch + add manage error boundary (LLM exploration) ([#6009](https://github.com/homarr-labs/homarr/issues/6009)) ([19f9769](https://github.com/homarr-labs/homarr/commit/19f9769d9e669d56d793e9f6a0d265316113d743)), closes [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008)
* **plex:** use series title for media releases instead of season label ([#6083](https://github.com/homarr-labs/homarr/issues/6083)) ([2443b89](https://github.com/homarr-labs/homarr/commit/2443b89b46f88fddbf41f0382a9f3bc5469c6fa8))
* show app first in add item modal ([#6087](https://github.com/homarr-labs/homarr/issues/6087)) ([915c8a3](https://github.com/homarr-labs/homarr/commit/915c8a3501551fdccf786d0c34dc01107d873ce0))
* **truenas:** report actual memory usage and pool free space ([#6017](https://github.com/homarr-labs/homarr/issues/6017)) ([f69e545](https://github.com/homarr-labs/homarr/commit/f69e545d6429aece0e55975c3b2426f20e91e545))
* **unraid:** report disk free space instead of total size ([#6018](https://github.com/homarr-labs/homarr/issues/6018)) ([1fb97ee](https://github.com/homarr-labs/homarr/commit/1fb97ee9c6fbe656a7dd3a9d1e436125982533f4))
* **widgets:** clean up health monitoring disk and size display ([#6019](https://github.com/homarr-labs/homarr/issues/6019)) ([e29c79a](https://github.com/homarr-labs/homarr/commit/e29c79a9028d9311dcb3d8323e0950a444589c7a))
* **widgets:** hide health monitoring default tab option for non-proxmox integrations ([#6021](https://github.com/homarr-labs/homarr/issues/6021)) ([52df56b](https://github.com/homarr-labs/homarr/commit/52df56b19da8e0bcb6c2e5df47b7936ec63f3149))
* **widgets:** hide system disk usage value when it overflows and show it in a tooltip ([#6022](https://github.com/homarr-labs/homarr/issues/6022)) ([a19c9d8](https://github.com/homarr-labs/homarr/commit/a19c9d88caacc86b42854c732818f0b5e5cc8dd5))
## [1.67.0](https://github.com/homarr-labs/homarr/compare/v1.66.1...v1.67.0) (2026-06-19)
### Features
* **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
+1 -187
View File
@@ -1,193 +1,7 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
# Specific features / integrations
# These owners come from the PR that introduced each integration implementation.
# Specific rules override the global fallback because CODEOWNERS uses the last
# matching pattern; do not include @homarr-labs/maintainers here unless they
# should also be requested for that integration.
# AdGuard Home - introduced in https://github.com/homarr-labs/homarr/pull/929 by @hillaliy
apps/docs/docs/integrations/adguard-home @hillaliy
packages/integrations/src/adguard-home @hillaliy
# Anchor - introduced in https://github.com/homarr-labs/homarr/pull/5040 by @ohmzi
apps/docs/docs/integrations/anchor @ohmzi
packages/integrations/src/anchor @ohmzi
# ArchiveTeam Warrior - introduced in https://github.com/homarr-labs/homarr/pull/5784 by @darbyjack
apps/docs/docs/integrations/archive-team-warrior @darbyjack
packages/integrations/src/archive-team-warrior @darbyjack
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/audiobookshelf @ajnart
packages/integrations/src/audiobookshelf @ajnart
# Beszel - introduced in https://github.com/homarr-labs/homarr/pull/5900 by @ajnart
apps/docs/docs/integrations/beszel @ajnart
packages/integrations/src/beszel @ajnart
# Codeberg - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/codeberg @Aandree5
packages/integrations/src/codeberg @Aandree5
# Coolify - introduced in https://github.com/homarr-labs/homarr/pull/4789 by @Rhiz3K
apps/docs/docs/integrations/coolify @Rhiz3K
packages/integrations/src/coolify @Rhiz3K
# Dash. - introduced in https://github.com/homarr-labs/homarr/pull/1541 by @manuel-rw
apps/docs/docs/integrations/dash-dot @manuel-rw
packages/integrations/src/dashdot @manuel-rw
# Docker Hub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/docker-hub @Aandree5
packages/integrations/src/docker-hub @Aandree5
# Emby - introduced in https://github.com/homarr-labs/homarr/pull/2466 by @Meierschlumpf
apps/docs/docs/integrations/emby @Meierschlumpf
packages/integrations/src/emby @Meierschlumpf
# GitHub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/github @Aandree5
packages/integrations/src/github @Aandree5
# GitHub Container Registry - introduced in https://github.com/homarr-labs/homarr/pull/3727 by @Aandree5
apps/docs/docs/integrations/github-containerregistry @Aandree5
packages/integrations/src/github-container-registry @Aandree5
# GitLab - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/gitlab @Aandree5
packages/integrations/src/gitlab @Aandree5
# Glances - introduced in https://github.com/homarr-labs/homarr/pull/5102 by @manuel-rw
apps/docs/docs/integrations/glances @manuel-rw
packages/integrations/src/glances @manuel-rw
# Gluetun - introduced in https://github.com/homarr-labs/homarr/pull/5812 by @Lok3sh-kumar
apps/docs/docs/integrations/gluetun @Lok3sh-kumar
packages/integrations/src/gluetun @Lok3sh-kumar
# Gotify - introduced in https://github.com/homarr-labs/homarr/pull/5847 by @Nerdy-Technician
apps/docs/docs/integrations/gotify @Nerdy-Technician
packages/integrations/src/gotify @Nerdy-Technician
# Home Assistant - introduced in https://github.com/homarr-labs/homarr/pull/578 by @manuel-rw
apps/docs/docs/integrations/home-assistant @manuel-rw
packages/integrations/src/homeassistant @manuel-rw
# ICal - introduced in https://github.com/homarr-labs/homarr/pull/3980 by @NicolasNewman
apps/docs/docs/integrations/ical @NicolasNewman
packages/integrations/src/ical @NicolasNewman
# Immich - introduced in https://github.com/homarr-labs/homarr/pull/5165 by @manuel-rw
apps/docs/docs/integrations/immich @manuel-rw
packages/integrations/src/immich @manuel-rw
# Jellyfin - introduced in https://github.com/homarr-labs/homarr/pull/672 by @manuel-rw
apps/docs/docs/integrations/jellyfin @manuel-rw
packages/integrations/src/jellyfin @manuel-rw
# Jellyseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
apps/docs/docs/integrations/jellyseerr @manuel-rw
packages/integrations/src/jellyseerr @manuel-rw
# LinuxServer.io - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
apps/docs/docs/integrations/linux-server-io @Aandree5
packages/integrations/src/linuxserverio @Aandree5
# Navidrome - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/navidrome @ajnart
packages/integrations/src/navidrome @ajnart
# Nextcloud - introduced in https://github.com/homarr-labs/homarr/pull/2501 by @manuel-rw
apps/docs/docs/integrations/nextcloud @manuel-rw
packages/integrations/src/nextcloud @manuel-rw
# npm - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/npm @Aandree5
packages/integrations/src/npm @Aandree5
# ntfy - introduced in https://github.com/homarr-labs/homarr/pull/2900 by @itzTheMeow
apps/docs/docs/integrations/ntfy @ajnart @manuel-rw
packages/integrations/src/ntfy @ajnart @manuel-rw
# OpenMediaVault - introduced in https://github.com/homarr-labs/homarr/pull/1142 by @hillaliy
apps/docs/docs/integrations/open-media-vault @hillaliy
packages/integrations/src/openmediavault @hillaliy
# OPNsense - introduced in https://github.com/homarr-labs/homarr/pull/3424 by @Oupsman
apps/docs/docs/integrations/opnsense @Oupsman
packages/integrations/src/opnsense @Oupsman
# Overseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
apps/docs/docs/integrations/overseerr @manuel-rw
packages/integrations/src/overseerr @manuel-rw
# Paperless-ngx - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/paperless-ngx @ajnart
packages/integrations/src/paperless-ngx @ajnart
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
apps/docs/docs/integrations/peanut @afonsojramos
packages/integrations/src/peanut @afonsojramos
# Pi-hole - introduced in https://github.com/homarr-labs/homarr/pull/521 by @Meierschlumpf
apps/docs/docs/integrations/pi-hole @Meierschlumpf
packages/integrations/src/pi-hole @Meierschlumpf
# Plex - introduced in https://github.com/homarr-labs/homarr/pull/1342 by @hillaliy
apps/docs/docs/integrations/plex @hillaliy
packages/integrations/src/plex @hillaliy
# Prowlarr - introduced in https://github.com/homarr-labs/homarr/pull/965 by @hillaliy
apps/docs/docs/integrations/prowlarr @hillaliy
packages/integrations/src/prowlarr @hillaliy
# Proxmox - introduced in https://github.com/homarr-labs/homarr/pull/1969 by @Meierschlumpf
apps/docs/docs/integrations/proxmox @Meierschlumpf
packages/integrations/src/proxmox @Meierschlumpf
# Quay - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
apps/docs/docs/integrations/quay @Aandree5
packages/integrations/src/quay @Aandree5
# search.ch - introduced in https://github.com/homarr-labs/homarr/pull/5180 by @Meierschlumpf
apps/docs/docs/integrations/search-ch @Meierschlumpf
packages/integrations/src/search-ch @Meierschlumpf
# Speedtest Tracker - introduced in https://github.com/homarr-labs/homarr/pull/5363 by @kylegrieder
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
packages/integrations/src/speedtest-tracker @kylegrieder
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
apps/docs/docs/integrations/technitium-dns @lopsch
packages/integrations/src/technitium @lopsch
# Tracearr - introduced in https://github.com/homarr-labs/homarr/pull/5060 by @StrandedTurtle
apps/docs/docs/integrations/tracearr @StrandedTurtle
packages/integrations/src/tracearr @StrandedTurtle
# TrueNAS - introduced in https://github.com/homarr-labs/homarr/pull/3745 by @manuel-rw
apps/docs/docs/integrations/truenas @manuel-rw @afonsojramos
packages/integrations/src/truenas @manuel-rw @afonsojramos
# Umami - introduced in https://github.com/homarr-labs/homarr/pull/5362 by @febrile42
apps/docs/docs/integrations/umami @febrile42
packages/integrations/src/umami @febrile42
# UniFi Controller - introduced in https://github.com/homarr-labs/homarr/pull/2236 by @pitschi
apps/docs/docs/integrations/unifi-controller @pitschi
packages/integrations/src/unifi-controller @pitschi
# Unraid - introduced in https://github.com/homarr-labs/homarr/pull/4439 by @manuel-rw
apps/docs/docs/integrations/unraid @manuel-rw
packages/integrations/src/unraid @manuel-rw
# Uptime Kuma - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# Exempt Renovate-managed files (no owners)
# Exempt Renovate‑managed files (no owners)
package.json
**/package.json
package-lock.json
+1 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.18.0-alpine AS base
FROM node:24.16.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -42,7 +42,6 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
@@ -23,7 +23,6 @@ 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">
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Version: 8.19.0](https://img.shields.io/badge/Version-8.19.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
![AppVersion: v1.60.0](https://img.shields.io/badge/AppVersion-v1.60.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -383,7 +383,6 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
@@ -1,35 +0,0 @@
---
title: "ArchiveTeam Warrior"
description: "ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { archiveTeamWarriorIntegration } from ".";
<IntegrationHeader integration={archiveTeamWarriorIntegration} categories={["archiving"]} />
The ArchiveTeam Warrior integration lets Homarr connect to your ArchiveTeam Warrior instance so you can manage and monitor your archiving tasks directly from your dashboard.
### Adding the integration
<AddingIntegration />
### Secrets
ArchiveTeam Warrior does not require authentication by default. If your instance is protected with basic authentication, you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [],
},
]}
/>
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const archiveTeamWarriorIntegration: IntegrationDefinition = {
name: "ArchiveTeam Warrior",
description:
"ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive.",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
path: "../../integrations/archiveteam-warrior",
};
@@ -1,54 +0,0 @@
---
title: 'Docker labels'
description: 'Configure Homarr apps automatically using Docker container labels during onboarding.'
hide_title: true
---
# Docker label discovery
Homarr can read `homarr.*` labels on your containers to pre-populate apps during onboarding. Homepage-style `homepage.*` labels are supported as a fallback when no `homarr.name` label is set.
## Required labels
| Label | Description |
| --- | --- |
| `homarr.name` | Display name for the app |
| `homarr.group` | Category section name on the board |
| `homarr.href` | URL opened when clicking the app |
## Optional labels
| Label | Description |
| --- | --- |
| `homarr.icon` | Icon URL |
| `homarr.description` | App description |
| `homarr.ping` | Ping URL for status checks |
| `homarr.id` | Stable external ID (defaults to container ID) |
| `homarr.board` | Target board name |
| `homarr.integration` | Integration kind (e.g. `sonarr`) for onboarding integration setup |
| `homarr.widget` | Widget kind hint (parsed, not auto-placed yet) |
| `homarr.hide` | Hide container from discovery |
## Homepage fallback
When `homarr.name` is absent, Homarr reads `homepage.name`, `homepage.group`, `homepage.href`, `homepage.icon`, and `homepage.description` if homepage label reading is enabled (default: on).
## Example
```yaml
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
labels:
homarr.name: "Sonarr"
homarr.group: "Media"
homarr.href: "http://sonarr:8989"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
homarr.integration: "sonarr"
```
## Onboarding behavior
During the integrations step, Homarr scans labeled containers first. Labeled services appear with their configured name, group, and URL. Unlabeled running containers still use image-based discovery as a fallback.
When you finish onboarding, selected label-based apps are synced into category sections on your board. Integration widgets are placed separately in the default section.
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const dockerLabelsIntegration: IntegrationDefinition = {
name: "Docker labels",
description: "Configure Homarr apps automatically using Docker container labels.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
path: "../../integrations/docker-labels",
};
+2 -4
View File
@@ -93,11 +93,9 @@ They are then available in the app list and can be used as normally added apps.
### Configuration
Homarr supports Docker labels for automatic app discovery during onboarding. See [Docker labels](../docker-labels/index.mdx) for the full reference.
Currently Homarr supports one label for configuration of the containers:
Currently Homarr supports one label for hiding containers:
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget, tools page, and label discovery.
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget and tools page.
### Security
Mounting docker sockets can be risky, as they permit full control over your docker service.
@@ -1,52 +0,0 @@
---
title: 'Gluetun'
description: 'Connect Gluetun to Homarr as a VPN integration.'
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { gluetunIntegration } from '.';
import { vpnWidget } from '@site/docs/widgets/vpn';
<IntegrationHeader
integration={gluetunIntegration}
categories={['VPN', 'Network']}
/>
[Gluetun](https://github.com/qdm12/gluetun) exposes a control server that Homarr can query to report the VPN tunnel
status, DNS status, public IP address, and active provider details in the VPN widget.
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: vpnWidget,
}]}
/>
### Setting up the Gluetun control server
Refer to the [Gluetun control server documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication) to enable and configure authentication.
### Adding the integration
<AddingIntegration />
### Secrets
Gluetun's control server can be left unprotected or secured with either an API key or HTTP Basic authentication.
Choose the option that matches how your Gluetun instance integration is set up.
<IntegrationSecrets secrets={[{
credentials: [],
steps: [],
},{
credentials: ['username', 'password'],
steps: [],
},{
credentials: ['apiKey'],
steps: [],
},,]} />
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const gluetunIntegration: IntegrationDefinition = {
name: "Gluetun",
description: "VPN control server integration for Homarr's VPN widget.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg",
path: "../../integrations/gluetun",
};
+1 -3
View File
@@ -63,9 +63,7 @@ import withoutIntegrations from './img/without-integrations.png';
### Interactions
- Hover over a marked date to preview its events in a popover.
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
- Click the date again or click outside to close the pinned popover.
- View calendar events by clicking on the marked dates.
### Adding the widget
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

-40
View File
@@ -1,40 +0,0 @@
---
title: 'VPN'
description: 'Monitor the status of your VPN integrations.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { vpnWidget } from '.';
import { gluetunIntegration } from '@site/docs/integrations/gluetun';
<WidgetHeader
widget={vpnWidget}
categories={['Network', 'Security']}
/>
The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS
services are up, the currently exposed public IP address, and the VPN provider and protocol being used.
When multiple VPN integrations are attached, the widget renders one card per integration so you can compare their
current status at a glance.
### Screenshots
import singleIntegrationImage from './img/single.png';
<div>
<img src={singleIntegrationImage} alt="VPN widget showing a single integration" width={128 * 3} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: gluetunIntegration,
}]} />
### Adding the widget
<AddingWidget />
-9
View File
@@ -1,9 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconShieldLock } from "@tabler/icons-react";
export const vpnWidget: WidgetDefinition = {
icon: IconShieldLock,
name: "VPN",
description: "Monitor the connection status, public IP, and provider details of your VPN integrations.",
path: "../../widgets/vpn",
};
+2 -2
View File
@@ -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.1",
"@tailwindcss/postcss": "^4.3.0",
"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.1"
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.1",
-1
View File
@@ -89,7 +89,6 @@
"mcp-handler": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"posthog-js": "catalog:",
"prismjs": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
@@ -50,7 +50,7 @@ interface IntegrationDraft {
initialUrl: string;
initialName?: string;
dockerPort?: number | null;
source: "manual" | "docker" | "label";
source: "manual" | "docker";
}
export const InitIntegrations = () => {
@@ -65,7 +65,6 @@ export const InitIntegrations = () => {
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
const { mutateAsync: syncLabelApps } = clientApi.onboard.syncDockerLabelApps.useMutation();
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
undefined,
@@ -99,7 +98,7 @@ export const InitIntegrations = () => {
initialUrl: item.suggestedUrl,
initialName: item.containerName,
dockerPort: item.publishedPort,
source: item.source,
source: "docker",
});
}
newKinds.add(item.kind);
@@ -133,8 +132,7 @@ export const InitIntegrations = () => {
const newDrafts = new Map(drafts);
for (const kind of selectedKinds) {
const existing = newDrafts.get(kind);
const useUrlTemplate = baseHost && existing?.source !== "label";
if (useUrlTemplate) {
if (baseHost) {
newDrafts.set(kind, {
kind,
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
@@ -154,32 +152,10 @@ export const InitIntegrations = () => {
const finishSetupAsync = async () => {
setPhase("done");
try {
const selectedApps = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
const labelApps = selectedApps.filter((app) => app.source === "label");
const imageApps = selectedApps.filter((app) => app.source !== "label");
if (labelApps.length > 0) {
const labelAppsWithHost = labelApps.filter((app): app is typeof app & { host: string } => Boolean(app.host));
const missingHostCount = labelApps.length - labelAppsWithHost.length;
if (missingHostCount > 0) {
throw new Error("Some selected Docker label apps are missing host metadata");
}
if (labelAppsWithHost.length > 0) {
const syncResult = await syncLabelApps(
labelAppsWithHost.map((app) => ({
containerId: app.containerId,
host: app.host,
})),
);
if (syncResult.notFound > 0) {
throw new Error("Some selected Docker label apps were not found");
}
}
}
if (imageApps.length > 0) {
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
if (appsToCreate.length > 0) {
await createApps(
imageApps.map((app) => {
appsToCreate.map((app) => {
const href = baseHost
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
: app.suggestedUrl;
@@ -191,7 +167,6 @@ export const InitIntegrations = () => {
}),
);
}
await setupIntegrations();
router.refresh();
} catch {
@@ -361,16 +336,9 @@ export const InitIntegrations = () => {
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500} lineClamp={1}>
{app.containerName}
</Text>
{app.group && (
<Text size="xs" c="dimmed" lineClamp={1}>
{app.group}
</Text>
)}
</Stack>
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
@@ -391,14 +359,8 @@ export const InitIntegrations = () => {
{t("action.skip")}
</Button>
<Button
onClick={() => {
if (selectedKinds.length === 0) {
void finishSetupAsync();
return;
}
handleContinueToConfig();
}}
disabled={(selectedKinds.length === 0 && selectedAppCount === 0) || undefined}
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0 || undefined}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
suppressHydrationWarning
>
@@ -442,8 +404,7 @@ const ConfigurePhase = ({
const currentKind = selectedKinds[currentIndex]!;
const draft = drafts.get(currentKind);
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
const useUrlTemplate = baseHost && draft?.source !== "label";
const computedUrl = useUrlTemplate
const computedUrl = baseHost
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
: draft?.initialUrl;
+1 -1
View File
@@ -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,3 +1,4 @@
import { headers } from "next/headers";
import Image from "next/image";
import {
Accordion,
@@ -40,10 +41,11 @@ import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
import crowdinContributors from "@static-data/translators.json";
import type { PackageJsonDependencies } from "~/versions/package-reader";
import { getPackageVersion } from "~/versions/package-reader";
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
import classes from "./about.module.css";
export async function generateMetadata() {
@@ -54,10 +56,32 @@ export async function generateMetadata() {
};
}
const getHostAsync = async () => {
if (process.env.HOSTNAME) {
return `${process.env.HOSTNAME}:3000`;
}
return (await headers()).get("host");
};
export default async function AboutPage() {
const baseServerUrl = `http://${await getHostAsync()}`;
const t = await getScopedI18n("management.page.about");
const version = getPackageVersion();
const dependencies = await getDependenciesAsync();
const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
res.json(),
)) as PackageJsonDependencies;
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
res.json(),
)) as typeof githubContributorsJson;
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
res.json(),
)) as typeof crowdinContributorsJson;
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
(res) => res.json(),
)) as typeof openCollectiveContributorsJson;
return (
<div>
@@ -161,7 +185,7 @@ export default async function AboutPage() {
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
.sort(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
@@ -1,42 +0,0 @@
"use client";
import { useEffect } from "react";
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export default function ManageErrorPage({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
const tError = useScopedI18n("management.error");
const t = useI18n();
useEffect(() => {
console.error(error);
}, [error]);
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={48} stroke={1.5} />
<Title order={3}>{tError("title")}</Title>
<Text size="sm" c="dimmed" maw={480} ta="center">
{tError("text")}
</Text>
{error.digest && (
<Text size="xs" c="dimmed">
{tError("digest", { digest: error.digest })}
</Text>
)}
<Button variant="light" onClick={unstable_retry}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Center>
);
}
@@ -131,7 +131,6 @@ 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}
@@ -141,7 +140,6 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
<Button
type="button"
variant="default"
fullWidth
onClick={() =>
@@ -17,10 +17,9 @@ interface AnalyticsSettingsProps {
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const { instanceId: _, ...formDefaults } = initialData;
const form = useForm({
initialValues: formDefaults,
onValuesChange: (updatedValues, _prev) => {
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
@@ -10,7 +10,6 @@ import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
@@ -80,20 +79,17 @@ export default async function ApiPage() {
const toolGroups = getMcpToolGroups();
return (
<>
<DynamicBreadcrumb />
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
</>
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
);
}
@@ -6,7 +6,6 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
@@ -32,13 +31,10 @@ export default async function BackupPage() {
const t = await getScopedI18n("management.page.tool.backup");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
</>
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
);
}
@@ -149,20 +149,16 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
},
];
interface DockerTableProps {
initialData?: RouterOutputs["docker"]["getContainers"];
}
export function DockerTable({ initialData }: DockerTableProps) {
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData: { containers, timestamp },
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const relativeTime = useTimeAgo(data.timestamp);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
@@ -180,11 +176,8 @@ export function DockerTable({ initialData }: DockerTableProps) {
},
});
const containers = data?.containers ?? [];
const table = useTranslatedMantineReactTable({
data: containers,
state: { isLoading: isFetching },
data: data.containers,
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
@@ -197,7 +190,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }),
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
style: { minWidth: 300 },
autoFocus: true,
},
@@ -23,7 +23,7 @@ export default async function DockerPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable initialData={{ containers, timestamp }} />
<DockerTable containers={containers} timestamp={timestamp} />
</Stack>
</>
);
@@ -7,7 +7,6 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
@@ -25,7 +24,6 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
return (
<ManageContainer size="xl">
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, group.name]])} />
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import crowdinContributors from "@static-data/translators.json";
import crowdinContributors from "../../../../../../../../static-data/translators.json";
export const GET = () => {
return NextResponse.json(crowdinContributors);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import githubContributors from "@static-data/contributors.json";
import githubContributors from "../../../../../../../../static-data/contributors.json";
export const GET = () => {
return NextResponse.json(githubContributors);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json";
export const GET = () => {
return NextResponse.json(openCollectiveContributors);
@@ -211,17 +211,7 @@ describe("SQLite backup", () => {
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
// The old key must no longer recover the plaintext. With AES-256-CBC this
// usually fails PKCS7 padding validation (throws), but ~1/256 of the time the
// wrong key yields valid padding and returns garbage instead of throwing, so
// assert on the actual property (plaintext is unrecoverable) rather than throwing.
let decryptedWithOldKey: string | null = null;
try {
decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey);
} catch {
// Expected for most wrong keys: padding validation fails.
}
expect(decryptedWithOldKey).not.toBe(plaintext);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
sqlite.close();
});
@@ -142,8 +142,7 @@ export const useBackupAnalysis = () => {
phase: "error",
});
throw new Error(
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
{ cause: migrationErr },
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
);
}
@@ -1,5 +1,3 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -18,7 +16,6 @@ import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
interface BoardItemContentProps {
item: SectionItem;
@@ -33,33 +30,30 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
return (
<>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
</Card>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -89,7 +83,6 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -41,12 +41,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
return itemA.name.localeCompare(itemB.name);
}),
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
[t],
);
@@ -1,250 +0,0 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Menu, Switch } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
children: ReactNode;
}
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback(
(updatedItems: typeof board.items) => {
saveBoard({ ...board, items: updatedItems });
},
[board, saveBoard],
);
const openEditModal = useCallback(() => {
openModal(
{
kind: item.kind,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
if (!isEditMode) {
persistBoard(
board.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
...boardItem,
options: editResult.options,
advancedOptions: editResult.advancedOptions,
integrationIds: editResult.integrationIds,
},
),
);
}
},
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
);
}, [
openModal,
item,
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
integrationData,
currentDefinition,
settings,
isEditMode,
persistBoard,
board,
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
const newOptions = { ...options, [key]: checked };
updateItemOptions({ itemId: item.id, newOptions });
persistBoard(
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
);
},
[options, item.id, updateItemOptions, persistBoard, board],
);
if (!session) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
<>
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
);
};
@@ -10,8 +10,3 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.itemCard:global(.iframe-wrapper) {
background-color: transparent !important;
border-color: transparent !important;
}
@@ -57,7 +57,6 @@ export const categoryTranslationKeys: Record<string, string> = {
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
vpn: "integration.category.vpn",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
+11 -19
View File
@@ -1,24 +1,16 @@
"use client";
import Script from "next/script";
import { useEffect } from "react";
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
const POSTHOG_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
const POSTHOG_HOST = "https://hog.homarr.dev";
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);
export const Analytics = ({ enabled }: { enabled: boolean }) => {
useEffect(() => {
if (!enabled) return;
if (analytics?.enableGeneral) {
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
}
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;
return <></>;
};
@@ -1,10 +0,0 @@
"use client";
import { createModal } from "@homarr/modals";
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
defaultTitle: (t) => t("docker.title"),
size: 1400,
});
@@ -3,7 +3,6 @@ import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
@@ -12,10 +11,9 @@ import { UpdateIndicator } from "./update";
export const UserButton = async () => {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
@@ -14,8 +14,6 @@ interface DynamicBreadcrumbProps {
nonInteractable?: string[];
}
const categorySegments = new Set(["tools"]);
/**
* Breadcrumb is client side rendered. Elements are automatically
* calculated and translated using dynamic keys.
@@ -62,11 +60,8 @@ export const DynamicBreadcrumb = ({
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart);
const isNonInteractable =
nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
if (mappedValue) {
if (isNonInteractable) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
@@ -76,7 +71,7 @@ export const DynamicBreadcrumb = ({
);
}
if (isNonInteractable) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
}
@@ -5,15 +5,7 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import {
IconBrandDocker,
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -25,16 +17,14 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps {
children: ReactNode;
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
isDockerEnabled?: boolean;
}
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -44,7 +34,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
await signOut({
@@ -91,11 +80,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{t("docker")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
+7 -15
View File
@@ -35,22 +35,14 @@ export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> =
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
const workspaceConfigContent = await fsPromises.readFile(
path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
"utf-8",
);
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
try {
const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8");
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
return new Map(Object.entries(parseResult.catalog ?? {}));
} catch (error) {
// The workspace config is absent in Docker (only the runner stage is shipped),
// so a missing file is expected. Surface any other failure (e.g. malformed YAML).
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return new Map();
}
throw error;
}
return new Map(Object.entries(parseResult.catalog ?? {}));
};
const workspaceConfigCatalogSchema = z.object({
-1
View File
@@ -3,7 +3,6 @@
"compilerOptions": {
"paths": {
"~/*": ["./src/*"],
"@static-data/*": ["../../static-data/*"],
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
},
"plugins": [
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.0-trixie AS base
FROM node:24.16.0-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
+3 -3
View File
@@ -5,8 +5,8 @@
"better-sqlite3": "^12.10.0"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
},
"packageManager": "pnpm@11.9.0"
"packageManager": "pnpm@11.6.0"
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.68.1",
"version": "1.65.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -61,10 +61,10 @@
"vitest": "catalog:"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
},
"packageManager": "pnpm@11.9.0",
"packageManager": "pnpm@11.6.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
+3 -1
View File
@@ -26,7 +26,9 @@
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"posthog-node": "catalog:"
"@homarr/server-settings": "workspace:^0.1.0",
"@umami/node": "catalog:",
"superjson": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
-23
View File
@@ -1,23 +0,0 @@
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,
});
};
+2
View File
@@ -0,0 +1,2 @@
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
+2 -2
View File
@@ -1,2 +1,2 @@
export { trackEvent } from "./client";
export { sendServerAnalyticsAsync } from "./send-server-analytics";
export * from "./constants";
export * from "./send-server-analytics";
+63 -136
View File
@@ -1,16 +1,17 @@
import type { UmamiEventData } from "@umami/node";
import { Umami } from "@umami/node";
import { isProviderEnabled } from "@homarr/auth/server";
import { createId, Stopwatch } from "@homarr/common";
import { 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, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
accounts,
apiKeys,
apps,
boards,
cronJobConfigurations,
customWidgetDefinitions,
groups,
iconRepositories,
icons,
@@ -23,160 +24,86 @@ 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 { getPostHogClient } from "./client";
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
const logger = createLogger({ module: "analytics" });
type AnalyticsResult = "sent" | "disabled" | "failed";
// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL.
const getOrCreateInstanceId = async (
analyticsSettings: Awaited<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 getDatabaseType = (): "mysql" | "postgresql" | "sqlite" => {
if (isMysql()) return "mysql";
if (isPostgresql()) return "postgresql";
return "sqlite";
};
const sumGroupedCounts = (rows: { count: number }[]): number => rows.reduce((sum, row) => sum + row.count, 0);
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;
};
export const sendServerAnalyticsAsync = async (): Promise<AnalyticsResult> => {
export const sendServerAnalyticsAsync = async () => {
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 "disabled";
return;
}
const client = getPostHogClient();
const umamiInstance = new Umami();
umamiInstance.init({
hostUrl: UMAMI_HOST_URL,
websiteId: UMAMI_WEBSITE_ID,
});
try {
const instanceId = await getOrCreateInstanceId(analyticsSettings);
const enabledAuthProviders = getEnabledAuthProviders();
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 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 enabledAuthProviders = (["credentials", "oidc", "ldap"] as const).filter(isProviderEnabled);
const integrationKinds = await db
.select({ kind: integrations.kind, count: count(integrations.id) })
.from(integrations)
.groupBy(integrations.kind);
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,
integrationKinds.forEach((integrationKind) => {
analyticsData[`integration_${integrationKind.kind}`] = integrationKind.count;
});
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";
const response = await umamiInstance.track("server-analytics-v2", analyticsData);
if (!response.ok) {
logger.warn("Unable to send analytics to Umami instance");
}
logger.info(`Sent analytics in ${stopWatch.getElapsedInHumanWords()}`);
};
-1
View File
@@ -24,7 +24,6 @@
"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",
-1
View File
@@ -28,7 +28,6 @@ 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)),
});
-51
View File
@@ -1,51 +0,0 @@
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,
});
}),
});
+9 -25
View File
@@ -15,36 +15,20 @@ 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 },
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];
columns: {
id: true,
name: true,
url: true,
},
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,7 +27,6 @@ import {
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { byIdSchema } from "@homarr/validation/common";
import {
integrationCreateSchema,
@@ -470,10 +469,6 @@ 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({
@@ -495,9 +490,6 @@ 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");
+39 -170
View File
@@ -3,13 +3,10 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, inArray, like, or } from "@homarr/db";
import { eq, like, or } from "@homarr/db";
import { placeAllWidgetsAsync } from "@homarr/db/queries";
import { getServerSettingsAsync } from "@homarr/db/queries";
import {
apps,
boards,
groups,
icons,
integrationItems,
@@ -36,8 +33,6 @@ import { MissingSecretError, testConnectionAsync } from "../integration/integrat
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
const logger = createLogger({ module: "onboardDockerDiscovery" });
interface PortInfo {
IP?: string;
PublicPort?: number;
@@ -48,30 +43,6 @@ interface SuggestedUrlResult {
publishedPort: number | null;
}
type DockerDiscoverySource = "label" | "docker";
interface DiscoveredIntegration {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
source: DockerDiscoverySource;
group?: string;
}
interface DiscoveredApp {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
source: DockerDiscoverySource;
group?: string;
host?: string;
}
const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => {
const port = ports?.[0];
if (port?.PublicPort) {
@@ -174,91 +145,51 @@ export const onboardRouter = createTRPCRouter({
}),
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
const emptyResult = {
integrations: [] as DiscoveredIntegration[],
apps: [] as DiscoveredApp[],
integrations: [] as {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}[],
apps: [] as {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}[],
};
try {
const { DockerSingleton, dockerLabels, listDiscoveredContainersAsync } = await import("@homarr/docker");
const serverSettings = await getServerSettingsAsync(ctx.db);
const labeledServices = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
const labeledContainerIds = new Set(labeledServices.map((service) => service.containerId));
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations: DiscoveredIntegration[] = [];
const discoveredApps: DiscoveredApp[] = [];
for (const service of labeledServices) {
if (service.integrationKind && !seenKinds.has(service.integrationKind)) {
seenKinds.add(service.integrationKind);
discoveredIntegrations.push({
containerId: service.containerId,
containerName: service.name,
kind: service.integrationKind,
suggestedUrl: service.href,
publishedPort: null,
iconUrl: service.icon ?? null,
source: "label",
group: service.group,
});
continue;
}
discoveredApps.push({
containerId: service.containerId,
containerName: service.name,
suggestedUrl: service.href,
publishedPort: null,
iconUrl: service.icon ?? null,
source: "label",
group: service.group,
host: service.host,
});
}
const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
const dockerInstances = DockerSingleton.getInstances();
if (dockerInstances.length === 0) {
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
return {
status: hasResults ? ("success" as const) : ("empty" as const),
integrations: discoveredIntegrations,
apps: discoveredApps,
};
return { status: "empty" as const, ...emptyResult };
}
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containerList = await instance.listContainers({ all: false });
return containerList
.filter((container) => !(dockerLabels.hide in container.Labels))
.filter((container) => !labeledContainerIds.has(container.Id))
.map((container) => ({ ...container, host }));
return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
}),
);
const containers = results.flatMap((result, index) => {
if (result.status === "fulfilled") {
return result.value;
}
const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
logger.warn("Failed to list containers for onboarding discovery", {
host: dockerInstances[index]?.host,
cause: result.reason,
});
return [];
});
if (containers.length === 0 && discoveredIntegrations.length === 0 && discoveredApps.length === 0) {
if (containers.length === 0) {
return { status: "empty" as const, ...emptyResult };
}
const likeQueries = containers.map((container) =>
like(icons.name, `%${extractContainerImageName(container.Image)}%`),
);
const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : [];
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations = emptyResult.integrations;
const discoveredApps = emptyResult.apps;
const cdnIconUrl = (slug: string) =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
@@ -292,7 +223,6 @@ export const onboardRouter = createTRPCRouter({
suggestedUrl,
publishedPort,
iconUrl,
source: "docker",
});
} else if (!kind && dbIcon) {
discoveredApps.push({
@@ -301,61 +231,19 @@ export const onboardRouter = createTRPCRouter({
suggestedUrl,
publishedPort,
iconUrl,
source: "docker",
host: container.host,
});
}
}
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
return {
status: hasResults ? ("success" as const) : ("empty" as const),
status: "success" as const,
integrations: discoveredIntegrations,
apps: discoveredApps,
};
} catch (error) {
logger.warn("Docker discovery failed during onboarding", { cause: error });
} catch {
return { status: "unavailable" as const, ...emptyResult };
}
}),
syncDockerLabelApps: onboardingProcedure
.requiresStep("integrations")
.input(
z.array(
z.object({
containerId: z.string(),
host: z.string(),
}),
),
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) {
return { created: 0, updated: 0, skipped: 0, notFound: 0 };
}
const { listDiscoveredContainersAsync, syncDiscoveredServicesAsync } = await import("@homarr/docker");
const serverSettings = await getServerSettingsAsync(ctx.db);
const labeledServices = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
const selectedKeys = new Set(input.map((entry) => `${entry.host}/${entry.containerId}`));
const selectedServices = labeledServices.filter((service) =>
selectedKeys.has(`${service.host}/${service.containerId}`),
);
const syncResult = await syncDiscoveredServicesAsync(selectedServices, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
return {
...syncResult,
notFound: input.length - selectedServices.length,
};
}),
createAppsFromDiscovery: onboardingProcedure
.requiresStep("integrations")
.input(
@@ -369,17 +257,9 @@ export const onboardRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) return;
const existingApps = await ctx.db.query.apps.findMany({ columns: { href: true } });
const existingHrefs = new Set(
existingApps.map((app) => app.href).filter((href): href is string => Boolean(href)),
);
const appsToCreate = input.filter((app) => !app.href || !existingHrefs.has(app.href));
if (appsToCreate.length === 0) return;
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
await ctx.db.insert(apps).values(
appsToCreate.map((app) => ({
input.map((app) => ({
id: createId(),
name: app.name,
iconUrl: app.iconUrl ?? defaultIcon,
@@ -390,26 +270,16 @@ export const onboardRouter = createTRPCRouter({
}),
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
const db = ctx.db;
const serverSettings = await getServerSettingsAsync(db);
const allIntegrations = await db.query.integrations.findMany();
const allApps = await db.query.apps.findMany();
const labelSyncedAppIds = new Set(
(await db.query.dockerAppSources.findMany({ columns: { appId: true } })).map((source) => source.appId),
);
const appsForWidgetPlacement = allApps.filter((app) => !labelSyncedAppIds.has(app.id));
const boardList = await db.query.boards.findMany({
orderBy: asc(boards.name),
const board = await db.query.boards.findFirst({
with: {
sections: true,
layouts: true,
},
});
const targetBoardName = serverSettings.docker.targetBoardName;
const board = targetBoardName
? (boardList.find((entry) => entry.name === targetBoardName) ?? boardList.at(0))
: boardList.at(0);
if (!board) {
await nextOnboardingStepAsync(db, undefined);
@@ -427,16 +297,15 @@ export const onboardRouter = createTRPCRouter({
const layout = board.layouts[0];
if (section && layout) {
const emptySectionLayouts = await db.query.itemLayouts.findMany({
where: eq(itemLayouts.sectionId, section.id),
columns: { itemId: true },
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
});
const emptySectionItemIds = emptySectionLayouts.map((entry) => entry.itemId);
if (emptySectionItemIds.length > 0) {
await db.delete(integrationItems).where(inArray(integrationItems.itemId, emptySectionItemIds));
await db.delete(itemLayouts).where(inArray(itemLayouts.itemId, emptySectionItemIds));
await db.delete(items).where(inArray(items.id, emptySectionItemIds));
for (const item of existingItems) {
await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id));
await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id));
}
if (existingItems.length > 0) {
await db.delete(items).where(eq(items.boardId, board.id));
}
await placeAllWidgetsAsync(
@@ -448,7 +317,7 @@ export const onboardRouter = createTRPCRouter({
columnCount: layout.columnCount,
},
allIntegrations,
appsForWidgetPlacement,
allApps,
);
}
+6 -7
View File
@@ -24,18 +24,17 @@ export const serverSettingsRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
await updateServerSettingByKeyAsync(
ctx.db,
input.settingsKey,
input.value as ServerSettings[keyof ServerSettings],
);
}),
initSettings: onboardingProcedure
.requiresStep("settings")
.input(settingsInitSchema)
.mutation(async ({ ctx, input }) => {
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
@@ -75,6 +75,9 @@ describe("saveSettings", () => {
settingsKey: "analytics",
value: {
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
},
});
@@ -84,7 +87,9 @@ describe("saveSettings", () => {
settingKey: "analytics",
value: SuperJSON.stringify({
enableGeneral: true,
instanceId: null,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
}),
},
]);
+5 -109
View File
@@ -2,7 +2,6 @@ import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import {
@@ -15,8 +14,6 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "beszelRouter" });
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -28,9 +25,7 @@ export const beszelRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
const settled = await Promise.allSettled(
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
@@ -45,29 +40,6 @@ export const beszelRouter = createTRPCRouter({
};
}),
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getSystems integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
integrationUrl: integration?.url ?? "",
systems: [],
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getSystems completed", {
userId: ctx.session?.user?.id,
integrationIds,
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
return results;
}),
@@ -111,14 +83,7 @@ export const beszelRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getAlerts called", {
userId: ctx.session?.user?.id,
integrationIds,
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const settled = await Promise.allSettled(
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
@@ -135,7 +100,6 @@ export const beszelRouter = createTRPCRouter({
}
return {
integrationId: integration.id,
integrationName: integration.name,
alerts: alertsResult.data.alerts,
history: alertsResult.data.history,
systemNameMap,
@@ -143,30 +107,6 @@ export const beszelRouter = createTRPCRouter({
};
}),
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getAlerts integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
alerts: [],
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getAlerts completed", {
userId: ctx.session?.user?.id,
integrationIds,
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
return results;
}),
@@ -224,41 +164,13 @@ export const beszelRouter = createTRPCRouter({
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
logger.debug("getSystemStats called", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
try {
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
logger.debug("getSystemStats completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
return { integrationId: integration.id, ...data, updatedAt: timestamp };
} catch (error) {
logger.warn("getSystemStats failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
error,
});
return {
integrationId: integration.id,
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: error instanceof Error ? error.message : String(error),
};
}
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
}),
subscribeSystemStats: publicProcedure
@@ -278,11 +190,6 @@ export const beszelRouter = createTRPCRouter({
emit.error(new Error("No Beszel integration configured"));
return () => {};
}
logger.debug("subscribeSystemStats opened", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
const abortController = new AbortController();
@@ -292,12 +199,6 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
} catch (error) {
if (!abortController.signal.aborted) {
logger.warn("subscribeSystemStats error", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
error,
});
if (error instanceof Error) {
emit.error(error);
} else {
@@ -309,11 +210,6 @@ export const beszelRouter = createTRPCRouter({
return () => {
abortController.abort();
logger.debug("subscribeSystemStats closed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
};
});
}),
-1
View File
@@ -36,7 +36,6 @@ export const widgetRouter = createTRPCRouter({
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
});
-59
View File
@@ -1,59 +0,0 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createVpnIntegrationMiddleware = () =>
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = vpnSummaryHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
}),
subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"vpn"> }>;
summary: VpnSummary | null;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});
+5 -19
View File
@@ -20,24 +20,6 @@ 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",
@@ -59,7 +41,11 @@ export const createConfiguration = (
},
},
trustHost: true,
cookies: createCookies(useSecureCookies),
cookies: {
sessionToken: {
name: sessionTokenCookieName,
},
},
adapter,
providers: filterProviders([
Credentials(createCredentialsConfiguration(db)),
-5
View File
@@ -28,11 +28,6 @@ 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(),
+2 -4
View File
@@ -4,11 +4,9 @@ import { generateSecureRandomToken } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { getCurrentUserPermissionsAsync } from "./callbacks";
import { env } from "./env";
// 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`;
// Default of authjs
export const sessionTokenCookieName = "authjs.session-token";
export const expireDateAfter = (seconds: number) => {
return new Date(Date.now() + seconds * 1000);
-2
View File
@@ -20,7 +20,6 @@ interface CreateCronJobOptions {
runOnStart?: boolean;
preventManualExecution?: boolean;
preventCustomInterval?: boolean;
hidden?: boolean;
expectedMaximumDurationInMillis?: number;
beforeStart?: () => MaybePromise<void>;
}
@@ -122,7 +121,6 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
},
preventManualExecution: options.preventManualExecution ?? false,
preventCustomInterval: options.preventCustomInterval ?? false,
hidden: options.hidden ?? false,
timezone: creatorOptions.timezone,
};
};
-2
View File
@@ -21,7 +21,6 @@ import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
import { vpnJob } from "./jobs/integrations/vpn";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -58,7 +57,6 @@ const getJobGroup = () => {
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
vpn: vpnJob,
});
};
+10 -12
View File
@@ -122,17 +122,15 @@ export class JobManager {
> {
const configurations = await this.db.query.cronJobConfigurations.findMany();
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,
};
});
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,
};
});
}
}
+8 -1
View File
@@ -1,6 +1,8 @@
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";
@@ -8,8 +10,13 @@ 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();
});
@@ -1,14 +0,0 @@
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(vpnSummaryHandler.handler, {
widgetKinds: ["vpn"],
getInput: {
vpn: () => ({}),
},
}),
);
@@ -1,17 +0,0 @@
CREATE TABLE `docker_app_source` (
`host` varchar(512) NOT NULL,
`container_id` varchar(128) NOT NULL,
`external_id` varchar(512) NOT NULL,
`app_id` varchar(64) NOT NULL,
`board_id` varchar(64) NOT NULL,
`item_id` varchar(64),
`integration_id` varchar(64),
`updated_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
);
--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
@@ -295,13 +295,6 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1780764600000,
"tag": "0042_add_docker_app_source",
"breakpoints": true
}
]
}
@@ -1,17 +0,0 @@
CREATE TABLE "docker_app_source" (
"host" varchar(512) NOT NULL,
"container_id" varchar(128) NOT NULL,
"external_id" varchar(512) NOT NULL,
"app_id" varchar(64) NOT NULL,
"board_id" varchar(64) NOT NULL,
"item_id" varchar(64),
"integration_id" varchar(64),
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
);
--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
@@ -57,13 +57,6 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780764600000,
"tag": "0008_add_docker_app_source",
"breakpoints": true
}
]
}
+14 -10
View File
@@ -647,13 +647,9 @@ const seedDemoUserAsync = async (db: Database) => {
const seedDefaultCustomWidgetsAsync = async (db: Database) => {
const seedIds = CUSTOM_WIDGET_SEEDS.map((s) => s.id);
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 beforeCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
const seedValues = CUSTOM_WIDGET_SEEDS.filter((seed) => !existingIds.has(seed.id)).map((seed) => {
const seedValues = CUSTOM_WIDGET_SEEDS.map((seed) => {
const parsed = customWidgetImportSchema.parse(seed.data);
return {
id: seed.id,
@@ -672,14 +668,22 @@ const seedDefaultCustomWidgetsAsync = async (db: Database) => {
};
});
if (seedValues.length === 0) {
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) {
console.log("Skipping seeding of default custom widgets as they already exist");
return;
}
await db.insert(customWidgetDefinitions).values(seedValues);
console.log(`Created ${seedValues.length} default custom widgets through seeding process`);
console.log(`Created ${createdCount} default custom widgets through seeding process`);
};
const seedBoardWidgetsAsync = async (db: Database) => {
@@ -1,17 +0,0 @@
CREATE TABLE `docker_app_source` (
`host` text NOT NULL,
`container_id` text NOT NULL,
`external_id` text NOT NULL,
`app_id` text NOT NULL,
`board_id` text NOT NULL,
`item_id` text,
`integration_id` text,
`updated_at` integer NOT NULL,
PRIMARY KEY(`host`, `container_id`),
FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
@@ -281,13 +281,6 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1780764600000,
"tag": "0040_add_docker_app_source",
"breakpoints": true
}
]
}
-1
View File
@@ -51,7 +51,6 @@ export const {
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
dockerAppSources,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
-46
View File
@@ -547,33 +547,6 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = mysqlTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -853,22 +826,3 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
-46
View File
@@ -546,33 +546,6 @@ export const cronJobConfigurations = pgTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = pgTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -852,22 +825,3 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
-46
View File
@@ -536,33 +536,6 @@ export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
});
export const dockerAppSources = sqliteTable(
"docker_app_source",
{
host: text().notNull(),
containerId: text().notNull(),
externalId: text().notNull(),
appId: text()
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: text().references(() => items.id, { onDelete: "set null" }),
integrationId: text().references(() => integrations.id, { onDelete: "set null" }),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -842,22 +815,3 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
@@ -165,7 +165,6 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/adguard-home"
| "/docs/integrations/anchor"
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/beszel"
@@ -180,7 +179,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/github"
| "/docs/integrations/gitlab"
| "/docs/integrations/glances"
| "/docs/integrations/gluetun"
| "/docs/integrations/gotify"
| "/docs/integrations/home-assistant"
| "/docs/integrations/ical"
@@ -282,7 +280,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/video"
| "/docs/widgets/vpn"
| "/docs/widgets/weather"
| ""
| "/sitemap.xml"
@@ -340,7 +337,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/navidrome"
| "/docs/integrations/umami"
| "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/widgets/clock"
@@ -384,7 +380,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/audio-stats"
| "/docs/widgets/umami"
| "/docs/widgets/vpn"
| "/docs/widgets/ups"
| "/docs/widgets/beszel-system-table"
| "/docs/widgets/beszel-system-grid"
@@ -55,7 +55,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
@@ -43,7 +43,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
uptimeKuma: "uptime-kuma",
audioStats: "audio-stats",
umami: "umami",
vpn: "vpn",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
+1 -9
View File
@@ -489,20 +489,13 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
defaultPort: 8090,
},
gluetun: {
name: "Gluetun",
secretKinds: [["username", "password"], ["apiKey"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
},
archiveTeamWarrior: {
name: "ArchiveTeam Warrior",
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
defaultUrl: "http://localhost:8001",
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
documentationUrl: null,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
@@ -611,7 +604,6 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"vpn",
"archiving",
"ups",
"documents",
@@ -38,7 +38,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
vpn: getIntegrationKindsByCategory("vpn"),
anchorNote: ["anchor"],
};
-1
View File
@@ -41,7 +41,6 @@ export const widgetKinds = [
"uptimeKuma",
"audioStats",
"umami",
"vpn",
"archiveTeamWarrior",
"ups",
"beszelSystemTable",
+2 -6
View File
@@ -25,15 +25,11 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"dockerode": "catalog:",
"superjson": "catalog:"
"dockerode": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
"typescript": "catalog:"
}
}
@@ -1,472 +0,0 @@
import type { ContainerInfo } from "dockerode";
import { describe, expect, test } from "vitest";
import { dockerLabels, homepageLabels } from "../../labels";
import { parseContainerLabels } from "../parse-container-labels";
const createContainer = (
labels: Record<string, string>,
id = "container-abc123",
): Pick<ContainerInfo, "Id" | "Labels"> => ({
Id: id,
Labels: labels,
});
describe("parseContainerLabels", () => {
describe("homarr native labels", () => {
test("parses all homarr labels into DiscoveredService", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "https://jellyfin.local",
[dockerLabels.icon]: "https://cdn.example/icon.png",
[dockerLabels.description]: "Media server",
[dockerLabels.ping]: "https://jellyfin.local/health",
[dockerLabels.id]: "jellyfin-1",
[dockerLabels.board]: "homelab",
[dockerLabels.integration]: "jellyfin",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Media",
name: "Jellyfin",
href: "https://jellyfin.local",
icon: "https://cdn.example/icon.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
externalId: "jellyfin-1",
boardName: "homelab",
integrationKind: "jellyfin",
widgetKind: "mediaServer",
});
});
test("parses minimal required homarr labels (name, group, href)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Networking",
[dockerLabels.name]: "Pi-hole",
[dockerLabels.href]: "http://pihole:80/admin",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Networking",
name: "Pi-hole",
href: "http://pihole:80/admin",
externalId: "container-abc123",
});
});
test("uses container ID as externalId when homarr.id is absent", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
},
"sha256:deadbeef1234",
),
"socket",
);
expect(result?.externalId).toBe("sha256:deadbeef1234");
});
test("uses homarr.id as externalId when present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
[dockerLabels.id]: "plex-server-main",
}),
"socket",
);
expect(result?.externalId).toBe("plex-server-main");
});
});
describe("homepage fallback labels", () => {
test("uses homepage labels when homarr.name is absent", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
[homepageLabels.description]: "TV shows",
}),
"192.168.1.10:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "192.168.1.10:2375",
group: "Downloads",
name: "Sonarr",
href: "https://sonarr.local",
icon: "https://cdn.example/sonarr.png",
description: "TV shows",
externalId: "container-abc123",
});
});
test("does not use homepage fallback when readHomepageLabels is false", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
}),
"socket",
{ readHomepageLabels: false },
);
expect(result).toBeNull();
});
test("homepage labels have no ping, board, integration, or widget support", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Emby",
[homepageLabels.href]: "http://emby:8096",
}),
"socket",
);
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
});
});
describe("label priority and mixing", () => {
test("prefers homarr labels over homepage when both are present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Homarr Group",
[dockerLabels.name]: "Homarr Name",
[dockerLabels.href]: "https://homarr.local",
[homepageLabels.group]: "Homepage Group",
[homepageLabels.name]: "Homepage Name",
[homepageLabels.href]: "https://homepage.local",
}),
"socket",
);
expect(result?.group).toBe("Homarr Group");
expect(result?.name).toBe("Homarr Name");
expect(result?.href).toBe("https://homarr.local");
});
test("disables homepage fallback entirely when homarr.name is present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "MyApp",
[dockerLabels.href]: "http://myapp:3000",
[homepageLabels.group]: "FromHomepage",
}),
"socket",
);
expect(result).toBeNull();
});
test("uses homarr icon with homepage name/group/href when homarr.name absent but homarr.icon present", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Jellyfin",
[homepageLabels.href]: "http://jellyfin:8096",
[dockerLabels.icon]: "https://custom-icon.example/jf.png",
}),
"socket",
);
expect(result?.name).toBe("Jellyfin");
expect(result?.icon).toBe("https://custom-icon.example/jf.png");
});
});
describe("homarr.hide", () => {
test("skips container when homarr.hide is present with value 'true'", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "true",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is present with empty value", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is any string (key presence is enough)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "anything",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("missing required labels", () => {
test("returns null when group is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "NoGroup",
[dockerLabels.href]: "https://nogroup.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when name is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.href]: "https://noname.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when href is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "NoHref",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when homepage labels are incomplete (missing group)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.name]: "Incomplete",
[homepageLabels.href]: "http://incomplete.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("whitespace handling", () => {
test("trims whitespace from all label values", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: " Media ",
[dockerLabels.name]: " Jellyfin ",
[dockerLabels.href]: " https://jellyfin.local ",
[dockerLabels.icon]: " https://icon.png ",
[dockerLabels.description]: " A media server ",
}),
"socket",
);
expect(result?.group).toBe("Media");
expect(result?.name).toBe("Jellyfin");
expect(result?.href).toBe("https://jellyfin.local");
expect(result?.icon).toBe("https://icon.png");
expect(result?.description).toBe("A media server");
});
test("treats whitespace-only values as empty (missing)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: " ",
[dockerLabels.href]: "https://example.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("integration and widget kind validation", () => {
test("parses valid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Radarr",
[dockerLabels.href]: "http://radarr:7878",
[dockerLabels.integration]: "radarr",
}),
"socket",
);
expect(result?.integrationKind).toBe("radarr");
});
test("ignores invalid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom App",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.integration]: "nonExistentIntegration",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
test("parses valid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "http://jellyfin:8096",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result?.widgetKind).toBe("mediaServer");
});
test("ignores invalid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.widget]: "totallyFakeWidget",
}),
"socket",
);
expect(result?.widgetKind).toBeUndefined();
});
test("handles empty integration kind gracefully", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "App",
[dockerLabels.href]: "http://app:3000",
[dockerLabels.integration]: "",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
});
describe("real-world scenarios", () => {
test("homepage-only service (common migration use case)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Monitoring",
[homepageLabels.name]: "Grafana",
[homepageLabels.href]: "http://grafana:3000",
[homepageLabels.icon]: "grafana",
[homepageLabels.description]: "Metrics and dashboards",
}),
"tcp://192.168.1.50:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "tcp://192.168.1.50:2375",
group: "Monitoring",
name: "Grafana",
href: "http://grafana:3000",
icon: "grafana",
description: "Metrics and dashboards",
externalId: "container-abc123",
});
});
test("service targeting a specific board", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Infrastructure",
[dockerLabels.name]: "Traefik",
[dockerLabels.href]: "http://traefik:8080",
[dockerLabels.board]: "networking-board",
[dockerLabels.icon]: "https://cdn.example/traefik.svg",
}),
"socket",
);
expect(result?.boardName).toBe("networking-board");
});
test("service with all optional fields empty uses defaults", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Tools",
[dockerLabels.name]: "Portainer",
[dockerLabels.href]: "http://portainer:9000",
},
"abc123def456",
),
"socket",
);
expect(result?.icon).toBeUndefined();
expect(result?.description).toBeUndefined();
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
expect(result?.externalId).toBe("abc123def456");
});
test("container with no labels returns null", () => {
const result = parseContainerLabels(createContainer({}), "socket");
expect(result).toBeNull();
});
});
});
-8
View File
@@ -1,8 +0,0 @@
export type { DiscoveredService } from "./types";
export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
export {
syncDiscoveredServicesAsync,
type SyncDiscoveredServicesOptions,
type SyncDiscoveredServicesResult,
} from "./sync-discovered-services";
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff. Show more